Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)

* Changes from fix/fetch-before-pull-fetch

* feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count

* feat: Add validation for remote names and improve error handling

* Address PR comments and mobile layout fixes

* ```
refactor: Extract PR target resolution logic into dedicated service
```

* feat: Add app shell UI and improve service imports. Address PR comments

* fix: Improve security validation and cache handling in git operations

* feat: Add GET /list endpoint and improve parameter handling

* chore: Improve validation, accessibility, and error handling across apps

* chore: Format vite server port configuration

* fix: Add error handling for gh pr list command and improve offline fallbacks

* fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
gsxdsm
2026-02-19 21:55:12 -08:00
committed by GitHub
parent ee52333636
commit 7df2182818
80 changed files with 4729 additions and 1107 deletions

View File

@@ -468,7 +468,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? (
{feature.titleGenerating && !feature.title ? (
<div className="flex items-center gap-1.5 mb-1">
<Spinner size="xs" />
<span className="text-xs text-muted-foreground italic">Generating title...</span>

View File

@@ -96,8 +96,8 @@ export const KanbanColumn = memo(function KanbanColumn({
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14',
// Add padding at bottom if there's a footer action (less on mobile to reduce blank space)
footerAction && 'pb-12 sm:pb-14',
contentClassName
)}
ref={contentRef}
@@ -109,7 +109,7 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Floating Footer Action */}
{footerAction && (
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-4 sm:pt-6">
{footerAction}
</div>
)}

View File

@@ -297,7 +297,7 @@ export const ListRow = memo(function ListRow({
<span
className={cn(
'font-medium truncate',
feature.titleGenerating && 'animate-pulse text-muted-foreground'
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
)}
title={feature.title || feature.description}
>

View File

@@ -321,11 +321,11 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Spinner size="md" />

View File

@@ -493,7 +493,7 @@ export function CherryPickDialog({
if (step === 'select-commits') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-foreground" />

View File

@@ -11,6 +11,13 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
GitCommit,
Sparkles,
@@ -21,6 +28,7 @@ import {
File,
ChevronDown,
ChevronRight,
Upload,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
@@ -31,6 +39,11 @@ import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface RemoteInfo {
name: string;
url: string;
}
interface WorktreeInfo {
path: string;
branch: string;
@@ -178,6 +191,17 @@ export function CommitWorktreeDialog({
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Push after commit state
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [remotesFetched, setRemotesFetched] = useState(false);
const [remotesFetchError, setRemotesFetchError] = useState<string | null>(null);
// Track whether the commit already succeeded so retries can skip straight to push
const [commitSucceeded, setCommitSucceeded] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
@@ -190,6 +214,58 @@ export function CommitWorktreeDialog({
return map;
}, [parsedDiffs]);
// Fetch remotes when push option is enabled
const fetchRemotesForWorktree = useCallback(
async (worktreePath: string, signal?: { cancelled: boolean }) => {
setIsLoadingRemotes(true);
setRemotesFetchError(null);
try {
const api = getElectronAPI();
if (api?.worktree?.listRemotes) {
const result = await api.worktree.listRemotes(worktreePath);
if (signal?.cancelled) return;
setRemotesFetched(true);
if (result.success && result.result) {
const remoteInfos = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
// Auto-select 'origin' if available, otherwise first remote
if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
}
} else {
// API not available — mark fetch as complete with an error so the UI
// shows feedback instead of remaining in an empty/loading state.
setRemotesFetchError('Remote listing not available');
setRemotesFetched(true);
return;
}
} catch (err) {
if (signal?.cancelled) return;
// Don't mark as successfully fetched — show an error with retry instead
setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes');
console.warn('Failed to fetch remotes:', err);
} finally {
if (!signal?.cancelled) setIsLoadingRemotes(false);
}
},
[]
);
useEffect(() => {
if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) {
const signal = { cancelled: false };
fetchRemotesForWorktree(worktree.path, signal);
return () => {
signal.cancelled = true;
};
}
}, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
@@ -198,6 +274,14 @@ export function CommitWorktreeDialog({
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
// Reset push state
setPushAfterCommit(false);
setRemotes([]);
setSelectedRemote('');
setIsPushing(false);
setRemotesFetched(false);
setRemotesFetchError(null);
setCommitSucceeded(false);
let cancelled = false;
@@ -278,14 +362,64 @@ export function CommitWorktreeDialog({
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
/** Shared push helper — returns true if the push succeeded */
const performPush = async (
api: ReturnType<typeof getElectronAPI>,
worktreePath: string,
remoteName: string
): Promise<boolean> => {
if (!api?.worktree?.push) {
toast.error('Push API not available');
return false;
}
setIsPushing(true);
try {
const pushResult = await api.worktree.push(worktreePath, false, remoteName);
if (pushResult.success && pushResult.result) {
toast.success('Pushed to remote', {
description: pushResult.result.message,
});
return true;
} else {
toast.error(pushResult.error || 'Failed to push to remote');
return false;
}
} catch (pushErr) {
toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote');
return false;
} finally {
setIsPushing(false);
}
};
const handleCommit = async () => {
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
if (!worktree) return;
const api = getElectronAPI();
// If commit already succeeded on a previous attempt, skip straight to push (or close if no push needed)
if (commitSucceeded) {
if (pushAfterCommit && selectedRemote) {
const ok = await performPush(api, worktree.path, selectedRemote);
if (ok) {
onCommitted();
onOpenChange(false);
setMessage('');
}
} else {
onCommitted();
onOpenChange(false);
setMessage('');
}
return;
}
if (!message.trim() || selectedFiles.size === 0) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError('Worktree API not available');
return;
@@ -299,12 +433,27 @@ export function CommitWorktreeDialog({
if (result.success && result.result) {
if (result.result.committed) {
setCommitSucceeded(true);
toast.success('Changes committed', {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage('');
// Push after commit if enabled
let pushSucceeded = false;
if (pushAfterCommit && selectedRemote) {
pushSucceeded = await performPush(api, worktree.path, selectedRemote);
}
// Only close the dialog when no push was requested or the push completed successfully.
// If push failed, keep the dialog open so the user can retry.
if (!pushAfterCommit || pushSucceeded) {
onCommitted();
onOpenChange(false);
setMessage('');
} else {
// Commit succeeded but push failed — notify parent of commit but keep dialog open for retry
onCommitted();
}
} else {
toast.info('No changes to commit', {
description: result.result.message,
@@ -320,16 +469,30 @@ export function CommitWorktreeDialog({
}
};
// When the commit succeeded but push failed, allow retrying the push without
// requiring a commit message or file selection.
const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === 'Enter' &&
(e.metaKey || e.ctrlKey) &&
!isLoading &&
!isGenerating &&
message.trim() &&
selectedFiles.size > 0
!isPushing &&
!isGenerating
) {
handleCommit();
if (isPushRetry) {
// Push retry only needs a selected remote
if (selectedRemote) {
handleCommit();
}
} else if (
message.trim() &&
selectedFiles.size > 0 &&
!(pushAfterCommit && !selectedRemote)
) {
handleCommit();
}
}
};
@@ -390,8 +553,19 @@ export function CommitWorktreeDialog({
const allSelected = selectedFiles.size === files.length && files.length > 0;
// Prevent the dialog from being dismissed while a push is in progress.
// Overlay clicks and Escape key both route through onOpenChange(false); we
// intercept those here so the UI stays open until the push completes.
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && isPushing) {
// Ignore close requests during an active push.
return;
}
onOpenChange(nextOpen);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -580,9 +754,80 @@ export function CommitWorktreeDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
{/* Push after commit option */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="push-after-commit"
checked={pushAfterCommit}
onCheckedChange={(checked) => setPushAfterCommit(checked === true)}
/>
<Label
htmlFor="push-after-commit"
className="text-sm font-medium cursor-pointer flex items-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
Push to remote after commit
</Label>
</div>
{pushAfterCommit && (
<div className="ml-6 flex flex-col gap-1.5">
{isLoadingRemotes || (!remotesFetched && !remotesFetchError) ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading remotes...</span>
</div>
) : remotesFetchError ? (
<div className="flex items-center gap-2 text-sm text-destructive">
<span>Failed to load remotes.</span>
<button
className="text-xs underline hover:text-foreground transition-colors"
onClick={() => {
if (worktree) {
setRemotesFetchError(null);
}
}}
>
Retry
</button>
</div>
) : remotes.length === 0 && remotesFetched ? (
<p className="text-sm text-muted-foreground">
No remotes configured for this repository.
</p>
) : remotes.length > 0 ? (
<div className="flex items-center gap-2">
<Label
htmlFor="remote-select"
className="text-xs text-muted-foreground whitespace-nowrap"
>
Remote:
</Label>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select" className="h-8 text-xs flex-1">
<SelectValue placeholder="Select remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<span className="font-medium">{remote.name}</span>
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
{remote.url}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
commit
commit{pushAfterCommit ? ' & push' : ''}
</p>
</div>
@@ -590,23 +835,41 @@ export function CommitWorktreeDialog({
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading || isGenerating}
disabled={isLoading || isPushing || isGenerating}
>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
disabled={
isLoading ||
isPushing ||
isGenerating ||
(isPushRetry
? !selectedRemote
: !message.trim() ||
selectedFiles.size === 0 ||
(pushAfterCommit && !selectedRemote))
}
>
{isLoading ? (
{isLoading || isPushing ? (
<>
<Spinner size="sm" className="mr-2" />
Committing...
{isPushing ? 'Pushing...' : 'Committing...'}
</>
) : isPushRetry ? (
<>
<Upload className="w-4 h-4 mr-2" />
Retry Push
</>
) : (
<>
<GitCommit className="w-4 h-4 mr-2" />
Commit
{pushAfterCommit ? (
<Upload className="w-4 h-4 mr-2" />
) : (
<GitCommit className="w-4 h-4 mr-2" />
)}
{pushAfterCommit ? 'Commit & Push' : 'Commit'}
{selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ''}

View File

@@ -30,6 +30,7 @@ import { useWorktreeBranches } from '@/hooks/queries';
interface RemoteInfo {
name: string;
url: string;
branches?: string[];
}
interface WorktreeInfo {
@@ -74,13 +75,19 @@ export function CreatePRDialog({
// Remote selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
// Target remote: which remote to create the PR against (may differ from push remote)
const [selectedTargetRemote, setSelectedTargetRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
// without needing it in its dependency array (which would cause re-fetch loops)
const selectedRemoteRef = useRef<string>(selectedRemote);
const selectedTargetRemoteRef = useRef<string>(selectedTargetRemote);
useEffect(() => {
selectedRemoteRef.current = selectedRemote;
}, [selectedRemote]);
useEffect(() => {
selectedTargetRemoteRef.current = selectedTargetRemote;
}, [selectedTargetRemote]);
// Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
@@ -91,11 +98,52 @@ export function CreatePRDialog({
true // Include remote branches for PR base branch selection
);
// Determine if push remote selection is needed:
// Show when there are unpushed commits, no remote tracking branch, or uncommitted changes
// (uncommitted changes will be committed first, then pushed)
const branchHasRemote = branchesData?.hasRemoteBranch ?? false;
const branchAheadCount = branchesData?.aheadCount ?? 0;
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
// Filter out current worktree branch from the list
// When a target remote is selected, only show branches from that remote
const branches = useMemo(() => {
if (!branchesData?.branches) return [];
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
}, [branchesData?.branches, worktree?.branch]);
const allBranches = branchesData.branches
.map((b) => b.name)
.filter((name) => name !== worktree?.branch);
// If a target remote is selected and we have remote info with branches,
// only show that remote's branches (not branches from other remotes)
if (selectedTargetRemote) {
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
const targetBranchNames = new Set(targetRemoteInfo.branches);
// Filter to only include branches that exist on the target remote
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
return allBranches.filter((name) => {
// Check if the branch name matches a target remote branch directly
if (targetBranchNames.has(name)) return true;
// Check if it's a prefixed remote branch (e.g. "upstream/main")
const prefix = `${selectedTargetRemote}/`;
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
return true;
return false;
});
}
}
return allBranches;
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
useEffect(() => {
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
// Current base branch is not in the filtered list — pick the best match
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
setBaseBranch(mainBranch || branches[0]);
}
}, [branches, baseBranch]);
// Fetch remotes when dialog opens
const fetchRemotes = useCallback(async () => {
@@ -109,14 +157,15 @@ export function CreatePRDialog({
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
(r: { name: string; url: string }) => ({
(r: { name: string; url: string; branches?: { name: string }[] }) => ({
name: r.name,
url: r.url,
branches: r.branches?.map((b: { name: string }) => b.name) || [],
})
);
setRemotes(remoteInfos);
// Preserve existing selection if it's still valid; otherwise fall back to 'origin' or first remote
// Preserve existing push remote selection if it's still valid; otherwise fall back to 'origin' or first remote
if (remoteInfos.length > 0) {
const remoteNames = remoteInfos.map((r) => r.name);
const currentSelection = selectedRemoteRef.current;
@@ -126,6 +175,19 @@ export function CreatePRDialog({
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
// Preserve existing target remote selection if it's still valid
const currentTargetSelection = selectedTargetRemoteRef.current;
const currentTargetStillExists =
currentTargetSelection !== '' && remoteNames.includes(currentTargetSelection);
if (!currentTargetStillExists) {
// Default target remote: 'upstream' if it exists (fork workflow), otherwise same as push remote
const defaultTarget =
remoteInfos.find((r) => r.name === 'upstream') ||
remoteInfos.find((r) => r.name === 'origin') ||
remoteInfos[0];
setSelectedTargetRemote(defaultTarget.name);
}
}
}
} catch {
@@ -154,6 +216,7 @@ export function CreatePRDialog({
setShowBrowserFallback(false);
setRemotes([]);
setSelectedRemote('');
setSelectedTargetRemote('');
setIsGeneratingDescription(false);
operationCompletedRef.current = false;
}, [defaultBaseBranch]);
@@ -215,6 +278,7 @@ export function CreatePRDialog({
baseBranch,
draft: isDraft,
remote: selectedRemote || undefined,
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
});
if (result.success && result.result) {
@@ -348,7 +412,7 @@ export function CreatePRDialog({
Create Pull Request
</DialogTitle>
<DialogDescription className="break-words">
Push changes and create a pull request from{' '}
{worktree.hasChanges ? 'Push changes and create' : 'Create'} a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
@@ -482,8 +546,8 @@ export function CreatePRDialog({
</div>
<div className="flex flex-col gap-4">
{/* Remote selector - only show if multiple remotes are available */}
{remotes.length > 1 && (
{/* Push remote selector - only show when multiple remotes and there are commits to push */}
{remotes.length > 1 && needsPush && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Push to Remote</Label>
@@ -525,14 +589,46 @@ export function CreatePRDialog({
</div>
)}
{/* Target remote selector - which remote to create PR against */}
{remotes.length > 1 && (
<div className="grid gap-2">
<Label htmlFor="target-remote-select">Create PR Against</Label>
<Select value={selectedTargetRemote} onValueChange={setSelectedTargetRemote}>
<SelectTrigger id="target-remote-select">
<SelectValue placeholder="Select target remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The remote repository where the pull request will be created
</p>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Label htmlFor="base-branch">Base Remote Branch</Label>
<BranchAutocomplete
value={baseBranch}
onChange={setBaseBranch}
branches={branches}
placeholder="Select base branch..."
disabled={isLoadingBranches}
allowCreate={false}
emptyMessage="No matching branches found."
data-testid="base-branch-autocomplete"
/>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -10,9 +10,11 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, AlertCircle } from 'lucide-react';
import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { toast } from 'sonner';
/**
@@ -100,6 +102,145 @@ export function CreateWorktreeDialog({
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
// Base branch selection state
const [showBaseBranch, setShowBaseBranch] = useState(false);
const [baseBranch, setBaseBranch] = useState('');
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [availableBranches, setAvailableBranches] = useState<
Array<{ name: string; isRemote: boolean }>
>([]);
// When the branch list fetch fails, store a message to show the user and
// allow free-form branch entry via allowCreate as a fallback.
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
const branchFetchAbortRef = useRef<AbortController | null>(null);
// Fetch available branches (local + remote) when the base branch section is expanded
const fetchBranches = useCallback(
async (signal?: AbortSignal) => {
if (!projectPath) return;
setIsLoadingBranches(true);
try {
const api = getHttpApiClient();
// Fetch branches using the project path (use listBranches on the project root).
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
if (signal?.aborted) return;
if (branchResult.success && branchResult.result) {
setBranchFetchError(null);
setAvailableBranches(
branchResult.result.branches.map((b: { name: string; isRemote: boolean }) => ({
name: b.name,
isRemote: b.isRemote,
}))
);
} else {
// API returned success: false — treat as an error
const message =
branchResult.error || 'Failed to load branches. You can type a branch name manually.';
setBranchFetchError(message);
setAvailableBranches([{ name: 'main', isRemote: false }]);
}
} catch (err) {
// If aborted, don't update state
if (signal?.aborted) return;
const message =
err instanceof Error
? err.message
: 'Failed to load branches. You can type a branch name manually.';
setBranchFetchError(message);
// Provide 'main' as a safe fallback so the autocomplete is not empty,
// and enable free-form entry (allowCreate) so the user can still type
// any branch name when the remote list is unavailable.
setAvailableBranches([{ name: 'main', isRemote: false }]);
} finally {
if (!signal?.aborted) {
setIsLoadingBranches(false);
}
}
},
[projectPath]
);
// Fetch branches when the base branch section is expanded
useEffect(() => {
if (open && showBaseBranch) {
// Abort any previous in-flight fetch
branchFetchAbortRef.current?.abort();
const controller = new AbortController();
branchFetchAbortRef.current = controller;
fetchBranches(controller.signal);
}
return () => {
branchFetchAbortRef.current?.abort();
branchFetchAbortRef.current = null;
};
}, [open, showBaseBranch, fetchBranches]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
// Abort any in-flight branch fetch to prevent stale writes
branchFetchAbortRef.current?.abort();
branchFetchAbortRef.current = null;
setBranchName('');
setBaseBranch('');
setShowBaseBranch(false);
setError(null);
setAvailableBranches([]);
setBranchFetchError(null);
setIsLoadingBranches(false);
}
}, [open]);
// Build branch name list for the autocomplete, with local branches first then remote
const branchNames = useMemo(() => {
const local: string[] = [];
const remote: string[] = [];
for (const b of availableBranches) {
if (b.isRemote) {
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
if (!b.name.includes('/')) continue;
remote.push(b.name);
} else {
local.push(b.name);
}
}
// Local branches first, then remote branches
return [...local, ...remote];
}, [availableBranches]);
// Determine if the selected base branch is a remote branch.
// Also detect manually entered remote-style names (e.g. "origin/feature")
// so the UI shows the "Remote branch — will fetch latest" hint even when
// the branch isn't in the fetched availableBranches list.
const isRemoteBaseBranch = useMemo(() => {
if (!baseBranch) return false;
// If the branch list couldn't be fetched, availableBranches is a fallback
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
if (branchFetchError) return false;
// Check fetched branch list first
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
if (knownRemote) return true;
// Heuristic: if the branch contains '/' and isn't a known local branch,
// treat it as a remote ref (e.g. "origin/main")
if (baseBranch.includes('/')) {
const isKnownLocal = availableBranches.some((b) => b.name === baseBranch && !b.isRemote);
return !isKnownLocal;
}
return false;
}, [baseBranch, availableBranches, branchFetchError]);
const handleCreate = async () => {
if (!branchName.trim()) {
setError({ title: 'Branch name is required' });
@@ -116,6 +257,17 @@ export function CreateWorktreeDialog({
return;
}
// Validate baseBranch using the same allowed-character check as branchName to prevent
// shell-special characters or invalid git ref names from reaching the API.
const trimmedBaseBranch = baseBranch.trim();
if (trimmedBaseBranch && !validBranchRegex.test(trimmedBaseBranch)) {
setError({
title: 'Invalid base branch name',
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
});
return;
}
setIsLoading(true);
setError(null);
@@ -125,15 +277,22 @@ export function CreateWorktreeDialog({
setError({ title: 'Worktree API not available' });
return;
}
const result = await api.worktree.create(projectPath, branchName);
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
const effectiveBaseBranch = trimmedBaseBranch || undefined;
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
if (result.success && result.worktree) {
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch',
description: result.worktree.isNew
? `New branch created${baseDesc}`
: 'Using existing branch',
});
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false);
setBranchName('');
setBaseBranch('');
} else {
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
}
@@ -154,7 +313,7 @@ export function CreateWorktreeDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
@@ -181,19 +340,96 @@ export function CreateWorktreeDialog({
className="font-mono text-sm"
autoFocus
/>
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
{/* Base Branch Section - collapsible */}
<div className="grid gap-2">
<button
type="button"
onClick={() => setShowBaseBranch(!showBaseBranch)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
{showBaseBranch ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
<span>Base Branch</span>
{baseBranch && !showBaseBranch && (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
{baseBranch}
</code>
)}
</button>
{showBaseBranch && (
<div className="grid gap-2 pl-1">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Select a local or remote branch as the starting point
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
branchFetchAbortRef.current?.abort();
const controller = new AbortController();
branchFetchAbortRef.current = controller;
void fetchBranches(controller.signal);
}}
disabled={isLoadingBranches}
className="h-6 px-2 text-xs"
>
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
{branchFetchError && (
<div className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="w-3 h-3 flex-shrink-0" />
<span>Could not load branches: {branchFetchError}</span>
</div>
)}
<BranchAutocomplete
value={baseBranch}
onChange={(value) => {
setBaseBranch(value);
setError(null);
}}
branches={branchNames}
placeholder="Select base branch (default: HEAD)..."
disabled={isLoadingBranches}
allowCreate={!!branchFetchError}
/>
{isRemoteBaseBranch && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="w-3 h-3" />
<span>Remote branch will fetch latest before creating worktree</span>
</div>
)}
</div>
)}
</div>
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p>Examples:</p>
<ul className="list-disc list-inside pl-2 space-y-0.5">
@@ -218,7 +454,7 @@ export function CreateWorktreeDialog({
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Creating...
{isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
</>
) : (
<>

View File

@@ -24,8 +24,8 @@ interface MergeWorktreeDialogProps {
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Called when integration is successful. integratedWorktree indicates the integrated worktree and deletedBranch indicates if the branch was also deleted. */
onIntegrated: (integratedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
@@ -34,7 +34,7 @@ export function MergeWorktreeDialog({
onOpenChange,
projectPath,
worktree,
onMerged,
onIntegrated,
onCreateConflictResolutionFeature,
}: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false);
@@ -105,10 +105,10 @@ export function MergeWorktreeDialog({
if (result.success) {
const description = deleteWorktreeAndBranch
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
toast.success(`Branch merged to ${targetBranch}`, { description });
onMerged(worktree, deleteWorktreeAndBranch);
? `Branch "${worktree.branch}" has been integrated into "${targetBranch}" and the worktree and branch were deleted`
: `Branch "${worktree.branch}" has been integrated into "${targetBranch}"`;
toast.success(`Branch integrated into ${targetBranch}`, { description });
onIntegrated(worktree, deleteWorktreeAndBranch);
onOpenChange(false);
} else {
// Check if the error indicates merge conflicts
@@ -128,11 +128,11 @@ export function MergeWorktreeDialog({
conflictFiles: result.conflictFiles || [],
operationType: 'merge',
});
toast.error('Merge conflicts detected', {
toast.error('Integrate conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Failed to merge branch', {
toast.error('Failed to integrate branch', {
description: result.error,
});
}
@@ -153,11 +153,11 @@ export function MergeWorktreeDialog({
conflictFiles: [],
operationType: 'merge',
});
toast.error('Merge conflicts detected', {
toast.error('Integrate conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Failed to merge branch', {
toast.error('Failed to integrate branch', {
description: errorMessage,
});
}
@@ -191,12 +191,12 @@ export function MergeWorktreeDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
Integrate Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
There are conflicts when merging{' '}
There are conflicts when integrating{' '}
<code className="font-mono bg-muted px-1 rounded">
{mergeConflict.sourceBranch}
</code>{' '}
@@ -274,12 +274,12 @@ export function MergeWorktreeDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" />
Merge Branch
Integrate Branch
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
Integrate <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
into:
</span>
@@ -308,7 +308,7 @@ export function MergeWorktreeDialog({
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
commit or discard them before integrating.
</span>
</div>
)}
@@ -327,7 +327,7 @@ export function MergeWorktreeDialog({
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
Delete worktree and branch after merging
Delete worktree and branch after integrating
</Label>
</div>
@@ -353,12 +353,12 @@ export function MergeWorktreeDialog({
{isLoading ? (
<>
<Spinner size="sm" variant="foreground" className="mr-2" />
Merging...
Integrating...
</>
) : (
<>
<GitMerge className="w-4 h-4 mr-2" />
Merge
Integrate
</>
)}
</Button>

View File

@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />

View File

@@ -367,7 +367,7 @@ export function ViewStashesDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />

View File

@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />

View File

@@ -123,6 +123,7 @@ export function useBoardActions({
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
workMode?: 'current' | 'auto' | 'custom';
initialStatus?: 'backlog' | 'in_progress'; // Skip backlog flash when creating & starting immediately
}) => {
const workMode = featureData.workMode || 'current';
@@ -218,13 +219,15 @@ export function useBoardActions({
const needsTitleGeneration =
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
const initialStatus = featureData.initialStatus || 'backlog';
const newFeatureData = {
...featureData,
title: titleWasGenerated ? titleForBranch : featureData.title,
titleGenerating: needsTitleGeneration,
status: 'backlog' as const,
status: initialStatus,
branchName: finalBranchName,
dependencies: featureData.dependencies || [],
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it
@@ -608,20 +611,51 @@ export function useBoardActions({
}
}
const updates = {
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
// Skip status update if feature was already created with in_progress status
// (e.g., via "Make" button which creates directly as in_progress to avoid backlog flash)
const alreadyInProgress = feature.status === 'in_progress';
if (!alreadyInProgress) {
const updates = {
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
try {
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
} catch (error) {
// Rollback to backlog if persist fails (e.g., server offline)
logger.error('Failed to update feature status, rolling back to backlog:', error);
const rollbackUpdates = {
status: 'backlog' as const,
startedAt: undefined,
};
updateFeature(feature.id, rollbackUpdates);
persistFeatureUpdate(feature.id, rollbackUpdates).catch((persistError) => {
logger.error('Failed to persist rollback:', persistError);
});
if (isConnectionError(error)) {
handleServerOffline();
return false;
}
toast.error('Failed to start feature', {
description:
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
});
return false;
}
}
try {
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
logger.info('Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
} catch (error) {
// Rollback to backlog if persist or run fails (e.g., server offline)
// Rollback to backlog if run fails
logger.error('Failed to start feature, rolling back to backlog:', error);
const rollbackUpdates = {
status: 'backlog' as const,

View File

@@ -12,6 +12,7 @@ type ColumnId = Feature['status'];
interface UseBoardColumnFeaturesProps {
features: Feature[];
runningAutoTasks: string[];
runningAutoTasksAllWorktrees: string[]; // Running tasks across ALL worktrees (prevents backlog flash during event timing gaps)
searchQuery: string;
currentWorktreePath: string | null; // Currently selected worktree path
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
@@ -21,6 +22,7 @@ interface UseBoardColumnFeaturesProps {
export function useBoardColumnFeatures({
features,
runningAutoTasks,
runningAutoTasksAllWorktrees,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
@@ -38,6 +40,10 @@ export function useBoardColumnFeatures({
};
const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks);
// Track ALL running tasks across all worktrees to prevent features from
// briefly appearing in backlog during the timing gap between when the server
// starts executing a feature and when the UI receives the event/status update.
const allRunningTaskIds = new Set(runningAutoTasksAllWorktrees);
// Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim();
@@ -138,11 +144,28 @@ export function useBoardColumnFeatures({
return;
}
// Not running: place by status (and worktree filter)
// Not running (on this worktree): place by status (and worktree filter)
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
//
// 'ready' and 'interrupted' are transitional statuses that don't have dedicated columns:
// - 'ready': Feature has an approved plan, waiting to be picked up for execution
// - 'interrupted': Feature execution was aborted (e.g., user stopped it, server restart)
// Both display in the backlog column and need the same allRunningTaskIds race-condition
// protection as 'backlog' to prevent briefly flashing in backlog when already executing.
if (status === 'backlog' || status === 'ready' || status === 'interrupted') {
// IMPORTANT: Check if this feature is running on ANY worktree before placing in backlog.
// This prevents a race condition where the feature has started executing on the server
// (and is tracked in a different worktree's running list) but the disk status hasn't
// been updated yet or the UI hasn't received the worktree-scoped event.
// In that case, the feature would briefly flash in the backlog column.
if (allRunningTaskIds.has(f.id)) {
// Feature is running somewhere - show in in_progress if it matches this worktree,
// otherwise skip it (it will appear on the correct worktree's board)
if (matchesWorktree) {
map.in_progress.push(f);
}
} else if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
@@ -159,8 +182,12 @@ export function useBoardColumnFeatures({
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
// Unknown status - apply same allRunningTaskIds protection and default to backlog
if (allRunningTaskIds.has(f.id)) {
if (matchesWorktree) {
map.in_progress.push(f);
}
} else if (matchesWorktree) {
map.backlog.push(f);
}
}
@@ -199,6 +226,7 @@ export function useBoardColumnFeatures({
}, [
features,
runningAutoTasks,
runningAutoTasksAllWorktrees,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,

View File

@@ -6,7 +6,7 @@
*/
import { useState, useCallback, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useQueryClient, useIsRestoring } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -24,13 +24,24 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const queryClient = useQueryClient();
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
// Track whether React Query's IDB persistence layer is still restoring.
// During the restore window (~100-500ms on mobile), queries report
// isLoading=true because no data is in the cache yet. We suppress
// the full-screen spinner during this period to avoid a visible flash
// on PWA memory-eviction cold starts.
const isRestoring = useIsRestoring();
// Use React Query for features
const {
data: features = [],
isLoading,
isLoading: isQueryLoading,
refetch: loadFeatures,
} = useFeatures(currentProject?.path);
// Don't report loading while IDB cache restore is in progress —
// features will appear momentarily once the restore completes.
const isLoading = isQueryLoading && !isRestoring;
// Load persisted categories from file
const loadCategories = useCallback(async () => {
if (!currentProject) return;

View File

@@ -320,13 +320,13 @@ export function KanbanBoard({
return (
<div
className={cn(
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative',
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-0 sm:pb-4 relative',
'transition-opacity duration-200',
className
)}
style={backgroundImageStyle}
>
<div className="h-full py-1" style={containerStyle}>
<div className="h-full pt-1 pb-0 sm:pb-1" style={containerStyle}>
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (

View File

@@ -131,7 +131,7 @@ export function DevServerLogsPanel({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden dialog-fullscreen-mobile"
data-testid="dev-server-logs-panel"
compact
>

View File

@@ -81,10 +81,18 @@ interface WorktreeActionsDropdownProps {
isTestRunning?: boolean;
/** Active test session info for this worktree */
testSessionInfo?: TestSessionInfo;
/** List of available remotes for this worktree (used to show remote submenu) */
remotes?: Array<{ name: string; url: string }>;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
/** Pull from a specific remote, bypassing the remote selection dialog */
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -141,10 +149,14 @@ export function WorktreeActionsDropdown({
isStartingTests = false,
isTestRunning = false,
testSessionInfo,
remotes,
trackingRemote,
onOpenChange,
onPull,
onPush,
onPushNewBranch,
onPullWithRemote,
onPushWithRemote,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
@@ -217,9 +229,11 @@ export function WorktreeActionsDropdown({
: null;
// Determine if the changes/PR section has any visible items
const showCreatePR = (!worktree.isMain || worktree.hasChanges) && !hasPR;
// Show Create PR when no existing PR is linked
const showCreatePR = !hasPR;
const showPRInfo = hasPR && !!worktree.pr;
const hasChangesSectionContent = worktree.hasChanges || showCreatePR || showPRInfo;
const hasChangesSectionContent =
worktree.hasChanges || showCreatePR || showPRInfo || !!(onStashChanges || onViewStashes);
// Determine if the destructive/bottom section has any visible items
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
@@ -317,6 +331,25 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator />
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
</>
)}
{isDevServerRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
@@ -416,188 +449,6 @@ export function WorktreeActionsDropdown({
)}
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onPull(worktree)}
disabled={isPulling || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge & Rebase
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Stash operations - combined submenu or simple item */}
{(onStashChanges || onViewStashes) && (
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
{onViewStashes && worktree.hasChanges && onStashChanges ? (
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
onStashChanges(worktree);
}}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
Stash Changes
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Only one action is meaningful - render a simple menu item without submenu
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
<DropdownMenuSub>
@@ -723,6 +574,272 @@ export function WorktreeActionsDropdown({
Re-run Init Script
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
{remotes && remotes.length > 1 && onPullWithRemote ? (
// Multiple remotes - show split button: click main area to pull (default behavior),
// chevron opens submenu showing individual remotes to pull from
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onPull(worktree)}
disabled={isPulling || !isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
(!isGitOpsAvailable || isPulling) && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable || isPulling}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Pull from remote
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
disabled={isPulling || !isGitOpsAvailable}
className="text-xs"
>
<Download className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Single remote or no remotes - show simple menu item
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onPull(worktree)}
disabled={isPulling || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
{remotes && remotes.length > 1 && onPushWithRemote ? (
// Multiple remotes - show split button: click main area for default push behavior,
// chevron opens submenu showing individual remotes to push to
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={
isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable
}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
(!isGitOpsAvailable || isPushing) && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable || isPushing}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Push to remote
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
disabled={isPushing || !isGitOpsAvailable}
className="text-xs"
>
<Upload className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Single remote or no remotes - show simple menu item
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span>
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge & Rebase
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onMerge(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Integrate Branch
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
{worktree.hasChanges && (
@@ -731,6 +848,75 @@ export function WorktreeActionsDropdown({
View Changes
</DropdownMenuItem>
)}
{/* Stash operations - combined submenu or simple item.
Only render when at least one action is meaningful:
- (worktree.hasChanges && onStashChanges): stashing changes is possible
- onViewStashes: viewing existing stashes is possible
Without this guard, the item would appear clickable but be a silent no-op
when hasChanges is false and onViewStashes is undefined. */}
{((worktree.hasChanges && onStashChanges) || onViewStashes) && (
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
{onViewStashes && worktree.hasChanges && onStashChanges ? (
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
onStashChanges(worktree);
}}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
Stash Changes
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Only one action is meaningful - render a simple menu item without submenu
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
@@ -749,7 +935,7 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
{/* Show PR option when there is no existing PR (showCreatePR === !hasPR) */}
{showCreatePR && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
@@ -829,35 +1015,13 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
)}
{!worktree.isMain && (
<>
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onMerge(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge Branch
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -82,6 +82,10 @@ export interface WorktreeDropdownProps {
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string;
/** Per-worktree tracking remote lookup */
getTrackingRemote?: (worktreePath: string) => string | undefined;
gitRepoStatus: GitRepoStatus;
hasTestCommand: boolean;
isStartingTests: boolean;
@@ -121,6 +125,12 @@ export interface WorktreeDropdownProps {
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
/** Remotes cache: maps worktree path to list of remotes */
remotesCache?: Record<string, Array<{ name: string; url: string }>>;
/** Pull from a specific remote, bypassing the remote selection dialog */
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
}
/**
@@ -170,6 +180,8 @@ export function WorktreeDropdown({
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
gitRepoStatus,
hasTestCommand,
isStartingTests,
@@ -204,6 +216,9 @@ export function WorktreeDropdown({
onCherryPick,
onAbortOperation,
onContinueOperation,
remotesCache,
onPullWithRemote,
onPushWithRemote,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -470,6 +485,9 @@ export function WorktreeDropdown({
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={
getTrackingRemote ? getTrackingRemote(selectedWorktree.path) : trackingRemote
}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -482,10 +500,13 @@ export function WorktreeDropdown({
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
remotes={remotesCache?.[selectedWorktree.path]}
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onPullWithRemote={onPullWithRemote}
onPushWithRemote={onPushWithRemote}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}

View File

@@ -38,6 +38,8 @@ interface WorktreeTabProps {
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string;
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
@@ -93,6 +95,12 @@ interface WorktreeTabProps {
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
/** List of available remotes for this worktree (used to show remote submenu) */
remotes?: Array<{ name: string; url: string }>;
/** Pull from a specific remote, bypassing the remote selection dialog */
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
}
export function WorktreeTab({
@@ -116,6 +124,7 @@ export function WorktreeTab({
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
gitRepoStatus,
isAutoModeRunning = false,
isStartingTests = false,
@@ -158,6 +167,9 @@ export function WorktreeTab({
onContinueOperation,
hasInitScript,
hasTestCommand = false,
remotes,
onPullWithRemote,
onPushWithRemote,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -476,6 +488,7 @@ export function WorktreeTab({
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -488,10 +501,13 @@ export function WorktreeTab({
isStartingTests={isStartingTests}
isTestRunning={isTestRunning}
testSessionInfo={testSessionInfo}
remotes={remotes}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onPullWithRemote={onPullWithRemote}
onPushWithRemote={onPushWithRemote}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}

View File

@@ -1,7 +1,32 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useWorktreeBranches } from '@/hooks/queries';
import type { GitRepoStatus } from '../types';
/** Explicit return type for the useBranches hook */
export interface UseBranchesReturn {
branches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
filteredBranches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
/**
* @deprecated Use {@link getTrackingRemote}(worktreePath) instead — this value
* only reflects the last-queried worktree and is unreliable when multiple panels
* share the hook.
*/
trackingRemote: string | undefined;
/** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */
getTrackingRemote: (worktreePath: string) => string | undefined;
isLoadingBranches: boolean;
branchFilter: string;
setBranchFilter: (filter: string) => void;
resetBranchFilter: () => void;
fetchBranches: (worktreePath: string) => void;
/** Prune cached tracking-remote entries for worktree paths that no longer exist */
pruneStaleEntries: (activePaths: Set<string>) => void;
gitRepoStatus: GitRepoStatus;
}
/**
* Hook for managing branch data with React Query
*
@@ -9,7 +34,7 @@ import type { GitRepoStatus } from '../types';
* the current interface for backward compatibility. Tracks which
* worktree path is currently being viewed and fetches branches on demand.
*/
export function useBranches() {
export function useBranches(): UseBranchesReturn {
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
const [branchFilter, setBranchFilter] = useState('');
@@ -23,6 +48,31 @@ export function useBranches() {
const aheadCount = branchData?.aheadCount ?? 0;
const behindCount = branchData?.behindCount ?? 0;
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
const trackingRemote = branchData?.trackingRemote;
// Per-worktree tracking remote cache: keeps results from previous fetchBranches()
// calls so multiple WorktreePanel instances don't all share a single stale value.
const trackingRemoteByPathRef = useRef<Record<string, string | undefined>>({});
// Update cache whenever query data changes for the current path
useEffect(() => {
if (currentWorktreePath && branchData) {
trackingRemoteByPathRef.current[currentWorktreePath] = branchData.trackingRemote;
}
}, [currentWorktreePath, branchData]);
const getTrackingRemote = useCallback(
(worktreePath: string): string | undefined => {
// If asking about the currently active query path, use fresh data
if (worktreePath === currentWorktreePath) {
return trackingRemote;
}
// Otherwise fall back to the cached value from a previous fetch
return trackingRemoteByPathRef.current[worktreePath];
},
[currentWorktreePath, trackingRemote]
);
// Use conservative defaults (false) until data is confirmed
// This prevents the UI from assuming git capabilities before the query completes
const gitRepoStatus: GitRepoStatus = {
@@ -47,6 +97,16 @@ export function useBranches() {
setBranchFilter('');
}, []);
/** Remove cached tracking-remote entries for worktree paths that no longer exist. */
const pruneStaleEntries = useCallback((activePaths: Set<string>) => {
const cache = trackingRemoteByPathRef.current;
for (const key of Object.keys(cache)) {
if (!activePaths.has(key)) {
delete cache[key];
}
}
}, []);
const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase())
);
@@ -57,11 +117,14 @@ export function useBranches() {
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
pruneStaleEntries,
gitRepoStatus,
};
}

View File

@@ -96,11 +96,14 @@ export function WorktreePanel({
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
pruneStaleEntries,
gitRepoStatus,
} = useBranches();
@@ -410,7 +413,7 @@ export function WorktreePanel({
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
// Merge branch dialog state
// Integrate branch dialog state
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
@@ -434,6 +437,11 @@ export function WorktreePanel({
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
// Remotes cache: maps worktree path -> list of remotes (fetched when dropdown opens)
const [remotesCache, setRemotesCache] = useState<
Record<string, Array<{ name: string; url: string }>>
>({});
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
@@ -451,6 +459,21 @@ export function WorktreePanel({
};
}, [fetchWorktrees]);
// Prune stale tracking-remote cache entries and remotes cache when worktrees change
useEffect(() => {
const activePaths = new Set(worktrees.map((w) => w.path));
pruneStaleEntries(activePaths);
setRemotesCache((prev) => {
const next: typeof prev = {};
for (const key of Object.keys(prev)) {
if (activePaths.has(key)) {
next[key] = prev[key];
}
}
return next;
});
}, [worktrees, pruneStaleEntries]);
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
@@ -467,6 +490,23 @@ export function WorktreePanel({
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
// Fetch remotes for the submenu when the dropdown opens, but only if not already cached
if (!remotesCache[worktree.path]) {
const api = getHttpApiClient();
api.worktree
.listRemotes(worktree.path)
.then((result) => {
if (result.success && result.result) {
setRemotesCache((prev) => ({
...prev,
[worktree.path]: result.result!.remotes.map((r) => ({ name: r.name, url: r.url })),
}));
}
})
.catch((err) => {
console.warn('Failed to fetch remotes for worktree:', err);
});
}
}
};
@@ -606,10 +646,15 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true);
}, []);
// Handle pull completed - refresh worktrees
// Handle pull completed - refresh branches and worktrees
const handlePullCompleted = useCallback(() => {
// Refresh branch data (ahead/behind counts, tracking) and worktree list
// after GitPullDialog completes the pull operation
if (pullDialogWorktree) {
fetchBranches(pullDialogWorktree.path);
}
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]);
// Handle pull with remote selection when multiple remotes exist
// Now opens the pull dialog which handles stash management and conflict resolution
@@ -675,18 +720,37 @@ export function WorktreePanel({
const handleConfirmSelectRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (selectRemoteOperation === 'pull') {
// Open the pull dialog with the selected remote
// Open the pull dialog — let GitPullDialog manage the pull operation
// via its useEffect and onPulled callback (handlePullCompleted)
setPullDialogRemote(remote);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
await _handlePull(worktree, remote);
} else {
await handlePush(worktree, remote);
fetchBranches(worktree.path);
fetchWorktrees({ silent: true });
}
fetchBranches(worktree.path);
fetchWorktrees();
},
[selectRemoteOperation, _handlePull, handlePush, fetchBranches, fetchWorktrees]
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
);
// Handle pull with a specific remote selected from the submenu (bypasses the remote selection dialog)
const handlePullWithSpecificRemote = useCallback((worktree: WorktreeInfo, remote: string) => {
// Open the pull dialog — let GitPullDialog manage the pull operation
// via its useEffect and onPulled callback (handlePullCompleted)
setPullDialogRemote(remote);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}, []);
// Handle push to a specific remote selected from the submenu (bypasses the remote selection dialog)
const handlePushWithSpecificRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
await handlePush(worktree, remote);
fetchBranches(worktree.path);
fetchWorktrees({ silent: true });
},
[handlePush, fetchBranches, fetchWorktrees]
);
// Handle confirming the push to remote dialog
@@ -719,13 +783,13 @@ export function WorktreePanel({
setMergeDialogOpen(true);
}, []);
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
const handleMerged = useCallback(
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
// Handle integration completion - refresh worktrees and reassign features if branch was deleted
const handleIntegrated = useCallback(
(integratedWorktree: WorktreeInfo, deletedBranch: boolean) => {
fetchWorktrees();
// If the branch was deleted, notify parent to reassign features to main
if (deletedBranch && onBranchDeletedDuringMerge) {
onBranchDeletedDuringMerge(mergedWorktree.branch);
onBranchDeletedDuringMerge(integratedWorktree.branch);
}
},
[fetchWorktrees, onBranchDeletedDuringMerge]
@@ -777,6 +841,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(selectedWorktree.path)}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -789,10 +854,13 @@ export function WorktreePanel({
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
remotes={remotesCache[selectedWorktree.path]}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -952,13 +1020,13 @@ export function WorktreePanel({
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
{/* Integrate Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
projectPath={projectPath}
worktree={mergeWorktree}
onMerged={handleMerged}
onIntegrated={handleIntegrated}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
@@ -1019,6 +1087,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
getTrackingRemote={getTrackingRemote}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -1027,6 +1097,9 @@ export function WorktreePanel({
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1112,6 +1185,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
@@ -1126,6 +1200,9 @@ export function WorktreePanel({
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1191,6 +1268,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
@@ -1205,6 +1283,9 @@ export function WorktreePanel({
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1317,13 +1398,13 @@ export function WorktreePanel({
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
{/* Integrate Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
projectPath={projectPath}
worktree={mergeWorktree}
onMerged={handleMerged}
onIntegrated={handleIntegrated}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>