mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-21 11:23:07 +00:00
Feature: Add PR review comments and resolution, improve AI prompt handling (#790)
* feat: Add PR review comments and resolution endpoints, improve prompt handling * Feature: File Editor (#789) * feat: Add file management feature * feat: Add auto-save functionality to file editor * fix: Replace HardDriveDownload icon with Save icon for consistency * fix: Prevent recursive copy/move and improve shell injection prevention * refactor: Extract editor settings form into separate component * ``` fix: Improve error handling and stabilize async operations - Add error event handlers to GraphQL process spawns to prevent unhandled rejections - Replace execAsync with execFile for safer command execution and better control - Fix timeout cleanup in withTimeout generator to prevent memory leaks - Improve outdated comment detection logic by removing redundant condition - Use resolveModelString for consistent model string handling - Replace || with ?? for proper falsy value handling in dialog initialization - Add comments clarifying branch name resolution logic for local branches with slashes - Add catch handler for project selection to handle async errors gracefully ``` * refactor: Extract PR review comments logic to dedicated service * fix: Improve robustness and UX for PR review and file operations * fix: Consolidate exec utilities and improve type safety * refactor: Replace ScrollArea with div and improve file tree layout
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
@@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron';
|
||||
import { SummaryDialog } from './summary-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
/**
|
||||
* Formats thinking level for compact display
|
||||
@@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
summary,
|
||||
isActivelyRunning,
|
||||
}: AgentInfoPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||
// Track real-time task status updates from WebSocket events
|
||||
@@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
pollingInterval,
|
||||
});
|
||||
|
||||
// On mount, ensure feature and agent output queries are fresh.
|
||||
// This handles the worktree switch scenario where cards unmount when filtered out
|
||||
// and remount when the user switches back. Without this, the React Query cache
|
||||
// may serve stale data (or no data) for the individual feature query, causing
|
||||
// the todo list to appear empty until the next polling cycle.
|
||||
useEffect(() => {
|
||||
if (shouldFetchData && projectPath && feature.id && !contextContent) {
|
||||
// Invalidate both the single feature and agent output queries to trigger immediate refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.single(projectPath, feature.id),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
|
||||
});
|
||||
}
|
||||
// Only run on mount (feature.id and projectPath identify this specific card instance)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [feature.id, projectPath]);
|
||||
|
||||
// Parse agent output into agentInfo
|
||||
const agentInfo = useMemo(() => {
|
||||
if (contextContent) {
|
||||
@@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
|
||||
// Agent Info Panel for non-backlog cards
|
||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||
// OR if the feature has effective todos from any source (handles initial mount after worktree switch)
|
||||
// OR if the feature is actively running (ensures panel stays visible during execution)
|
||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||
// (The backlog case was already handled above and returned early)
|
||||
if (agentInfo || hasPlanSpecTasks) {
|
||||
if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
|
||||
@@ -123,6 +123,18 @@ interface AddFeatureDialogProps {
|
||||
* This is used when the "Default to worktree mode" setting is disabled.
|
||||
*/
|
||||
forceCurrentBranchMode?: boolean;
|
||||
/**
|
||||
* Pre-filled title for the feature (e.g., from a GitHub issue).
|
||||
*/
|
||||
prefilledTitle?: string;
|
||||
/**
|
||||
* Pre-filled description for the feature (e.g., from a GitHub issue).
|
||||
*/
|
||||
prefilledDescription?: string;
|
||||
/**
|
||||
* Pre-filled category for the feature (e.g., 'From GitHub').
|
||||
*/
|
||||
prefilledCategory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +161,9 @@ export function AddFeatureDialog({
|
||||
projectPath,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
prefilledTitle,
|
||||
prefilledDescription,
|
||||
prefilledCategory,
|
||||
}: AddFeatureDialogProps) {
|
||||
const isSpawnMode = !!parentFeature;
|
||||
const navigate = useNavigate();
|
||||
@@ -211,6 +226,11 @@ export function AddFeatureDialog({
|
||||
wasOpenRef.current = open;
|
||||
|
||||
if (justOpened) {
|
||||
// Initialize with prefilled values if provided, otherwise use defaults
|
||||
setTitle(prefilledTitle ?? '');
|
||||
setDescription(prefilledDescription ?? '');
|
||||
setCategory(prefilledCategory ?? '');
|
||||
|
||||
setSkipTests(defaultSkipTests);
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
// Otherwise, use the default branch
|
||||
@@ -254,6 +274,9 @@ export function AddFeatureDialog({
|
||||
forceCurrentBranchMode,
|
||||
parentFeature,
|
||||
allFeatures,
|
||||
prefilledTitle,
|
||||
prefilledDescription,
|
||||
prefilledCategory,
|
||||
]);
|
||||
|
||||
// Clear requirePlanApproval when planning mode is skip or lite
|
||||
|
||||
@@ -105,43 +105,106 @@ export function CreatePRDialog({
|
||||
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 [];
|
||||
const allBranches = branchesData.branches
|
||||
.map((b) => b.name)
|
||||
.filter((name) => name !== worktree?.branch);
|
||||
// Determine the active remote to scope branches to.
|
||||
// For multi-remote: use the selected target remote.
|
||||
// For single remote: automatically scope to that remote.
|
||||
const activeRemote = useMemo(() => {
|
||||
if (remotes.length === 1) return remotes[0].name;
|
||||
if (selectedTargetRemote) return selectedTargetRemote;
|
||||
return '';
|
||||
}, [remotes, selectedTargetRemote]);
|
||||
|
||||
// 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;
|
||||
// Filter branches by the active remote and strip remote prefixes for display.
|
||||
// Returns display names (e.g. "main") without remote prefix.
|
||||
// Also builds a map from display name → full ref (e.g. "origin/main") for PR creation.
|
||||
const { branches, branchFullRefMap } = useMemo(() => {
|
||||
if (!branchesData?.branches)
|
||||
return { branches: [], branchFullRefMap: new Map<string, string>() };
|
||||
|
||||
const refMap = new Map<string, string>();
|
||||
|
||||
// If we have an active remote with branch info from the remotes endpoint, use that as the source
|
||||
const activeRemoteInfo = activeRemote
|
||||
? remotes.find((r) => r.name === activeRemote)
|
||||
: undefined;
|
||||
|
||||
if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) {
|
||||
// Use the remote's branch list — these are already short names (e.g. "main")
|
||||
const filteredBranches = activeRemoteInfo.branches
|
||||
.filter((branchName) => {
|
||||
// Exclude the current worktree branch
|
||||
return branchName !== worktree?.branch;
|
||||
})
|
||||
.map((branchName) => {
|
||||
// Map display name to full ref
|
||||
const fullRef = `${activeRemote}/${branchName}`;
|
||||
refMap.set(branchName, fullRef);
|
||||
return branchName;
|
||||
});
|
||||
|
||||
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||
}
|
||||
|
||||
// Fallback: if no remote info available, use the branches from the branches endpoint
|
||||
// Filter and strip prefixes
|
||||
const seen = new Set<string>();
|
||||
const filteredBranches: string[] = [];
|
||||
|
||||
for (const b of branchesData.branches) {
|
||||
// Skip the current worktree branch
|
||||
if (b.name === worktree?.branch) continue;
|
||||
|
||||
if (b.isRemote) {
|
||||
// Remote branch: check if it belongs to the active remote
|
||||
const slashIndex = b.name.indexOf('/');
|
||||
if (slashIndex === -1) continue;
|
||||
|
||||
const remoteName = b.name.substring(0, slashIndex);
|
||||
const branchName = b.name.substring(slashIndex + 1);
|
||||
|
||||
// If we have an active remote, only include branches from that remote
|
||||
if (activeRemote && remoteName !== activeRemote) continue;
|
||||
|
||||
// Strip the remote prefix for display
|
||||
if (!seen.has(branchName)) {
|
||||
seen.add(branchName);
|
||||
filteredBranches.push(branchName);
|
||||
refMap.set(branchName, b.name);
|
||||
}
|
||||
} else {
|
||||
// Local branch — only include if it has a remote counterpart on the active remote
|
||||
// or if no active remote is set (no remotes at all)
|
||||
if (!activeRemote) {
|
||||
if (!seen.has(b.name)) {
|
||||
seen.add(b.name);
|
||||
filteredBranches.push(b.name);
|
||||
refMap.set(b.name, b.name);
|
||||
}
|
||||
}
|
||||
// When active remote is set, skip local-only branches — the remote version
|
||||
// will be included from the remote branches above
|
||||
}
|
||||
}
|
||||
|
||||
return allBranches;
|
||||
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
|
||||
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||
}, [branchesData?.branches, worktree?.branch, activeRemote, 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]);
|
||||
// Strip any existing remote prefix from the current base branch for comparison
|
||||
const strippedBaseBranch = baseBranch.includes('/')
|
||||
? baseBranch.substring(baseBranch.indexOf('/') + 1)
|
||||
: baseBranch;
|
||||
|
||||
// Check if the stripped version exists in the list
|
||||
if (branches.includes(strippedBaseBranch)) {
|
||||
setBaseBranch(strippedBaseBranch);
|
||||
} else {
|
||||
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
||||
setBaseBranch(mainBranch || branches[0]);
|
||||
}
|
||||
}
|
||||
}, [branches, baseBranch]);
|
||||
|
||||
@@ -234,7 +297,16 @@ export function CreatePRDialog({
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
|
||||
// Resolve the display name to the actual branch name for the API
|
||||
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||
// Only strip the remote prefix if the resolved ref differs from the original
|
||||
// (indicating it was resolved from a full ref like "origin/main").
|
||||
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
||||
const branchNameForApi =
|
||||
resolvedRef !== baseBranch && resolvedRef.includes('/')
|
||||
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
||||
: resolvedRef;
|
||||
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
||||
|
||||
if (result.success) {
|
||||
if (result.title) {
|
||||
@@ -270,12 +342,26 @@ export function CreatePRDialog({
|
||||
setError('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
// Resolve the display branch name to the full ref for the API call.
|
||||
// The baseBranch state holds the display name (e.g. "main"), but the API
|
||||
// may need the short name without the remote prefix. We pass the display name
|
||||
// since the backend handles branch resolution. However, if the full ref is
|
||||
// available, we can use it for more precise targeting.
|
||||
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||
// Only strip the remote prefix if the resolved ref differs from the original
|
||||
// (indicating it was resolved from a full ref like "origin/main").
|
||||
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
||||
const baseBranchForApi =
|
||||
resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/')
|
||||
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
|
||||
: resolvedBaseBranch;
|
||||
|
||||
const result = await api.worktree.createPR(worktree.path, {
|
||||
projectPath: projectPath || undefined,
|
||||
commitMessage: commitMessage || undefined,
|
||||
prTitle: title || worktree.branch,
|
||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||
baseBranch,
|
||||
baseBranch: baseBranchForApi,
|
||||
draft: isDraft,
|
||||
remote: selectedRemote || undefined,
|
||||
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
||||
@@ -626,9 +712,13 @@ export function CreatePRDialog({
|
||||
onChange={setBaseBranch}
|
||||
branches={branches}
|
||||
placeholder="Select base branch..."
|
||||
disabled={isLoadingBranches}
|
||||
disabled={isLoadingBranches || isLoadingRemotes}
|
||||
allowCreate={false}
|
||||
emptyMessage="No matching branches found."
|
||||
emptyMessage={
|
||||
activeRemote
|
||||
? `No branches found on remote "${activeRemote}".`
|
||||
: 'No matching branches found.'
|
||||
}
|
||||
data-testid="base-branch-autocomplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -102,6 +105,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
@@ -128,6 +132,12 @@ interface WorktreeActionsDropdownProps {
|
||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
/** Terminal quick scripts configured for the project */
|
||||
terminalScripts?: TerminalScript[];
|
||||
/** Callback to run a terminal quick script in a new terminal session */
|
||||
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||
/** Callback to open the script editor UI */
|
||||
onEditScripts?: () => void;
|
||||
}
|
||||
|
||||
export function WorktreeActionsDropdown({
|
||||
@@ -166,6 +176,7 @@ export function WorktreeActionsDropdown({
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
@@ -184,6 +195,9 @@ export function WorktreeActionsDropdown({
|
||||
onAbortOperation,
|
||||
onContinueOperation,
|
||||
hasInitScript,
|
||||
terminalScripts,
|
||||
onRunTerminalScript,
|
||||
onEditScripts,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
const { editors } = useAvailableEditors();
|
||||
@@ -238,6 +252,21 @@ export function WorktreeActionsDropdown({
|
||||
// Determine if the destructive/bottom section has any visible items
|
||||
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
||||
|
||||
// Pre-compute PR info for the PR submenu (avoids an IIFE in JSX)
|
||||
const prInfo = useMemo<PRInfo | null>(() => {
|
||||
if (!showPRInfo || !worktree.pr) return null;
|
||||
return {
|
||||
number: worktree.pr.number,
|
||||
title: worktree.pr.title,
|
||||
url: worktree.pr.url,
|
||||
state: worktree.pr.state,
|
||||
author: '',
|
||||
body: '',
|
||||
comments: [],
|
||||
reviewComments: [],
|
||||
};
|
||||
}, [showPRInfo, worktree.pr]);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -358,19 +387,18 @@ export function WorktreeActionsDropdown({
|
||||
? 'Dev Server Starting...'
|
||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
disabled={devServerInfo?.urlDetected === false}
|
||||
aria-label={
|
||||
devServerInfo?.urlDetected === false
|
||||
? 'Open dev server in browser'
|
||||
: `Open dev server on port ${devServerInfo?.port} in browser`
|
||||
}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
{devServerInfo != null &&
|
||||
devServerInfo.port != null &&
|
||||
devServerInfo.urlDetected !== false && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
aria-label={`Open dev server on port ${devServerInfo.port} in browser`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Logs
|
||||
@@ -575,12 +603,57 @@ export function WorktreeActionsDropdown({
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{!worktree.isMain && hasInitScript && (
|
||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
Re-run Init Script
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Scripts submenu - consolidates init script and terminal quick scripts */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
Scripts
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRunInitScript(worktree)}
|
||||
className="text-xs"
|
||||
disabled={!hasInitScript}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
Re-run Init Script
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Terminal quick scripts */}
|
||||
{terminalScripts && terminalScripts.length > 0 ? (
|
||||
terminalScripts.map((script) => (
|
||||
<DropdownMenuItem
|
||||
key={script.id}
|
||||
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
||||
className="text-xs"
|
||||
disabled={!onRunTerminalScript}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
||||
<span className="truncate">{script.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||
No scripts configured
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Divider before Edit Commands & Scripts */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onEditScripts?.()}
|
||||
className="text-xs"
|
||||
disabled={!onEditScripts}
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
||||
Edit Commands & Scripts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
||||
@@ -815,32 +888,67 @@ export function WorktreeActionsDropdown({
|
||||
</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 && (
|
||||
{/* View Commits - split button when Cherry Pick is available:
|
||||
click main area to view commits directly, chevron opens sub-menu with Cherry Pick */}
|
||||
{onCherryPick ? (
|
||||
<DropdownMenuSub>
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - opens commit history directly */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!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>
|
||||
{/* Chevron trigger for sub-menu containing Cherry Pick */}
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={!isGitOpsAvailable}
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuSubContent>
|
||||
{/* Cherry-pick commits from another branch */}
|
||||
<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>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||
onClick={() => isGitOpsAvailable && onViewCommits(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
|
||||
<History className="w-3.5 h-3.5 mr-2" />
|
||||
View Commits
|
||||
{!isGitOpsAvailable && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
@@ -849,81 +957,67 @@ export function WorktreeActionsDropdown({
|
||||
)}
|
||||
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Stash operations - combined submenu or simple item.
|
||||
{/* View Changes split button - main action views changes directly, chevron reveals stash options.
|
||||
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) */}
|
||||
- worktree.hasChanges: View Changes action is available
|
||||
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
||||
- onViewStashes: viewing existing stashes is possible */}
|
||||
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */}
|
||||
{worktree.hasChanges && (onStashChanges || onViewStashes) ? (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - view changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onViewChanges(worktree)}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Changes
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{onStashChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!isGitOpsAvailable}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<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'
|
||||
)}
|
||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
Stash Changes
|
||||
Create Stash
|
||||
{!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>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{onViewStashes && (
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : worktree.hasChanges ? (
|
||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Changes
|
||||
</DropdownMenuItem>
|
||||
) : onViewStashes ? (
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
@@ -961,43 +1055,52 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Show PR info and Address Comments button if PR exists */}
|
||||
{showPRInfo && worktree.pr && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||
PR #{worktree.pr.number}
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
||||
{worktree.pr.state}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
// Convert stored PR info to the full PRInfo format for the handler
|
||||
// The handler will fetch full comments from GitHub
|
||||
const prInfo: PRInfo = {
|
||||
number: worktree.pr!.number,
|
||||
title: worktree.pr!.title,
|
||||
url: worktree.pr!.url,
|
||||
state: worktree.pr!.state,
|
||||
author: '', // Will be fetched
|
||||
body: '', // Will be fetched
|
||||
comments: [],
|
||||
reviewComments: [],
|
||||
};
|
||||
onAddressPRComments(worktree, prInfo);
|
||||
}}
|
||||
className="text-xs text-blue-500 focus:text-blue-600"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||
Address PR Comments
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
||||
{prInfo && worktree.pr && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - opens PR in browser */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||
PR #{worktree.pr.number}
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto mr-1 text-[10px] px-1.5 py-0.5 rounded uppercase',
|
||||
worktree.pr.state === 'MERGED'
|
||||
? 'bg-purple-500/20 text-purple-600'
|
||||
: worktree.pr.state === 'CLOSED'
|
||||
? 'bg-gray-500/20 text-gray-500'
|
||||
: 'bg-green-500/20 text-green-600'
|
||||
)}
|
||||
>
|
||||
{worktree.pr.state}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with PR actions */}
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onAddressPRComments(worktree, prInfo)}
|
||||
className="text-xs text-blue-500 focus:text-blue-600"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||
Manage PR Comments
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onAutoAddressPRComments(worktree, prInfo)}
|
||||
className="text-xs text-blue-500 focus:text-blue-600"
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||
Address PR Comments
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
||||
{worktree.hasChanges && (
|
||||
|
||||
@@ -144,8 +144,8 @@ export function WorktreeDropdownItem({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{devServerRunning && (
|
||||
{/* Dev server indicator - hidden when URL detection explicitly failed */}
|
||||
{devServerRunning && devServerInfo?.urlDetected !== false && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
||||
title={`Dev server running on port ${devServerInfo?.port}`}
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface WorktreeDropdownProps {
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
@@ -131,6 +132,12 @@ export interface WorktreeDropdownProps {
|
||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Terminal quick scripts configured for the project */
|
||||
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||
/** Callback to run a terminal quick script in a new terminal session */
|
||||
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||
/** Callback to open the script editor UI */
|
||||
onEditScripts?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +206,7 @@ export function WorktreeDropdown({
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
@@ -219,6 +227,9 @@ export function WorktreeDropdown({
|
||||
remotesCache,
|
||||
onPullWithRemote,
|
||||
onPushWithRemote,
|
||||
terminalScripts,
|
||||
onRunTerminalScript,
|
||||
onEditScripts,
|
||||
}: WorktreeDropdownProps) {
|
||||
// Find the currently selected worktree to display in the trigger
|
||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||
@@ -304,15 +315,11 @@ export function WorktreeDropdown({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{selectedStatus.devServerRunning && (
|
||||
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||
{selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||
title={
|
||||
selectedStatus.devServerInfo?.urlDetected === false
|
||||
? 'Dev server starting...'
|
||||
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
|
||||
}
|
||||
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
@@ -520,6 +527,7 @@ export function WorktreeDropdown({
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
@@ -538,6 +546,9 @@ export function WorktreeDropdown({
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={onRunTerminalScript}
|
||||
onEditScripts={onEditScripts}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,7 @@ interface WorktreeTabProps {
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
@@ -101,6 +102,12 @@ interface WorktreeTabProps {
|
||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||
/** Terminal quick scripts configured for the project */
|
||||
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||
/** Callback to run a terminal quick script in a new terminal session */
|
||||
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||
/** Callback to open the script editor UI */
|
||||
onEditScripts?: () => void;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -148,6 +155,7 @@ export function WorktreeTab({
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
@@ -170,6 +178,9 @@ export function WorktreeTab({
|
||||
remotes,
|
||||
onPullWithRemote,
|
||||
onPushWithRemote,
|
||||
terminalScripts,
|
||||
onRunTerminalScript,
|
||||
onEditScripts,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -440,7 +451,7 @@ export function WorktreeTab({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isDevServerRunning && (
|
||||
{isDevServerRunning && devServerInfo?.urlDetected !== false && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -517,6 +528,7 @@ export function WorktreeTab({
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
@@ -535,6 +547,9 @@ export function WorktreeTab({
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={onRunTerminalScript}
|
||||
onEditScripts={onEditScripts}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { normalizePath } from '@/lib/utils';
|
||||
@@ -11,10 +11,32 @@ interface UseDevServersOptions {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to build the browser-accessible dev server URL by rewriting the hostname
|
||||
* to match the current window's hostname (supports remote access).
|
||||
* Returns null if the URL is invalid or uses an unsupported protocol.
|
||||
*/
|
||||
function buildDevServerBrowserUrl(serverUrl: string): string | null {
|
||||
try {
|
||||
const devServerUrl = new URL(serverUrl);
|
||||
// Security: Only allow http/https protocols
|
||||
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
devServerUrl.hostname = window.location.hostname;
|
||||
return devServerUrl.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||
|
||||
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
||||
const toastShownForRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const fetchDevServers = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -25,10 +47,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
if (result.success && result.result?.servers) {
|
||||
const serversMap = new Map<string, DevServerInfo>();
|
||||
for (const server of result.result.servers) {
|
||||
serversMap.set(normalizePath(server.worktreePath), {
|
||||
const key = normalizePath(server.worktreePath);
|
||||
serversMap.set(key, {
|
||||
...server,
|
||||
urlDetected: server.urlDetected ?? true,
|
||||
});
|
||||
// Mark already-detected servers as having shown the toast
|
||||
// so we don't re-trigger on initial load
|
||||
if (server.urlDetected !== false) {
|
||||
toastShownForRef.current.add(key);
|
||||
}
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
@@ -41,7 +69,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
fetchDevServers();
|
||||
}, [fetchDevServers]);
|
||||
|
||||
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
|
||||
// Subscribe to all dev server lifecycle events for reactive state updates
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onDevServerLogEvent) return;
|
||||
@@ -54,6 +82,8 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
setRunningDevServers((prev) => {
|
||||
const existing = prev.get(key);
|
||||
if (!existing) return prev;
|
||||
// Avoid updating if already detected with same url/port
|
||||
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(key, {
|
||||
...existing,
|
||||
@@ -66,8 +96,53 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
});
|
||||
if (didUpdate) {
|
||||
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
||||
toast.success(`Dev server running on port ${port}`);
|
||||
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
||||
if (!toastShownForRef.current.has(key)) {
|
||||
toastShownForRef.current.add(key);
|
||||
const browserUrl = buildDevServerBrowserUrl(url);
|
||||
toast.success(`Dev server running on port ${port}`, {
|
||||
description: browserUrl ? browserUrl : url,
|
||||
action: browserUrl
|
||||
? {
|
||||
label: 'Open in Browser',
|
||||
onClick: () => {
|
||||
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'dev-server:stopped') {
|
||||
// Reactively remove the server from state when it stops
|
||||
const { worktreePath } = event.payload;
|
||||
const key = normalizePath(worktreePath);
|
||||
setRunningDevServers((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
// Clear the toast tracking so a fresh detection will show a new toast
|
||||
toastShownForRef.current.delete(key);
|
||||
logger.info(`Dev server stopped for ${worktreePath} (reactive update)`);
|
||||
} else if (event.type === 'dev-server:started') {
|
||||
// Reactively add/update the server when it starts
|
||||
const { worktreePath, port, url } = event.payload;
|
||||
const key = normalizePath(worktreePath);
|
||||
// Clear previous toast tracking for this key so a new detection triggers a fresh toast
|
||||
toastShownForRef.current.delete(key);
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, {
|
||||
worktreePath,
|
||||
port,
|
||||
url,
|
||||
urlDetected: false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -98,9 +173,12 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const key = normalizePath(targetPath);
|
||||
// Clear toast tracking so the new port detection shows a fresh toast
|
||||
toastShownForRef.current.delete(key);
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(normalizePath(targetPath), {
|
||||
next.set(key, {
|
||||
worktreePath: result.result!.worktreePath,
|
||||
port: result.result!.port,
|
||||
url: result.result!.url,
|
||||
@@ -135,11 +213,14 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const result = await api.worktree.stopDevServer(targetPath);
|
||||
|
||||
if (result.success) {
|
||||
const key = normalizePath(targetPath);
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(normalizePath(targetPath));
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
// Clear toast tracking so future restarts get a fresh toast
|
||||
toastShownForRef.current.delete(key);
|
||||
toast.success(result.result?.message || 'Dev server stopped');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to stop dev server');
|
||||
@@ -163,30 +244,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Rewrite URL hostname to match the current browser's hostname.
|
||||
// This ensures dev server URLs work when accessing Automaker from
|
||||
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
|
||||
const devServerUrl = new URL(serverInfo.url);
|
||||
|
||||
// Security: Only allow http/https protocols to prevent potential attacks
|
||||
// via data:, javascript:, file:, or other dangerous URL schemes
|
||||
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
|
||||
toast.error('Invalid dev server URL', {
|
||||
description: 'The server returned an unsupported URL protocol.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
devServerUrl.hostname = window.location.hostname;
|
||||
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse dev server URL:', error);
|
||||
toast.error('Failed to open dev server', {
|
||||
description: 'The server URL could not be processed. Please try again.',
|
||||
const browserUrl = buildDevServerBrowserUrl(serverInfo.url);
|
||||
if (!browserUrl) {
|
||||
logger.error('Invalid dev server URL:', serverInfo.url);
|
||||
toast.error('Invalid dev server URL', {
|
||||
description: 'The server returned an unsupported URL protocol.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
);
|
||||
|
||||
@@ -163,6 +163,24 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleRunTerminalScript = useCallback(
|
||||
(worktree: WorktreeInfo, command: string) => {
|
||||
// Navigate to the terminal view with the worktree path, branch, and command to run
|
||||
// The terminal view will create a new terminal and automatically execute the command
|
||||
navigate({
|
||||
to: '/terminal',
|
||||
search: {
|
||||
cwd: worktree.path,
|
||||
branch: worktree.branch,
|
||||
mode: 'tab' as const,
|
||||
nonce: Date.now(),
|
||||
command,
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
openInEditorMutation.mutate({
|
||||
@@ -204,6 +222,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleRunTerminalScript,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
// Stash confirmation state for branch switching
|
||||
|
||||
@@ -87,11 +87,28 @@ export function useWorktrees({
|
||||
}
|
||||
}, [worktrees, projectPath, setCurrentWorktree]);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
// Skip invalidation when re-selecting the already-active worktree
|
||||
const isSameWorktree = worktree.isMain
|
||||
? currentWorktreePath === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath ?? '');
|
||||
|
||||
if (isSameWorktree) return;
|
||||
|
||||
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
||||
|
||||
// Invalidate feature queries when switching worktrees to ensure fresh data.
|
||||
// Without this, feature cards that remount after the worktree switch may have stale
|
||||
// or missing planSpec/task data, causing todo lists to appear empty until the next
|
||||
// polling cycle or user interaction triggers a re-render.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(projectPath),
|
||||
});
|
||||
},
|
||||
[projectPath, setCurrentWorktree]
|
||||
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
|
||||
);
|
||||
|
||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||
@@ -110,7 +127,6 @@ export function useWorktrees({
|
||||
[projectPath, queryClient, refetch]
|
||||
);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
: worktrees.find((w) => w.isMain);
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface WorktreePanelProps {
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
/** Called when branch switch stash reapply results in merge conflicts */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
@@ -9,6 +10,7 @@ import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||
import { DEFAULT_TERMINAL_SCRIPTS } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||
import type {
|
||||
TestRunnerStartedEvent,
|
||||
TestRunnerOutputEvent,
|
||||
@@ -59,6 +61,7 @@ export function WorktreePanel({
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
onAddressPRComments,
|
||||
onAutoAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchSwitchConflict,
|
||||
@@ -116,6 +119,7 @@ export function WorktreePanel({
|
||||
handlePull: _handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleRunTerminalScript,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
pendingSwitch,
|
||||
@@ -209,6 +213,21 @@ export function WorktreePanel({
|
||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||
const hasTestCommand = !!projectSettings?.testCommand;
|
||||
|
||||
// Get terminal quick scripts from project settings (or fall back to defaults)
|
||||
const terminalScripts = useMemo(() => {
|
||||
const configured = projectSettings?.terminalScripts;
|
||||
if (configured && configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
return DEFAULT_TERMINAL_SCRIPTS;
|
||||
}, [projectSettings?.terminalScripts]);
|
||||
|
||||
// Navigate to project settings to edit scripts
|
||||
const navigate = useNavigate();
|
||||
const handleEditScripts = useCallback(() => {
|
||||
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } });
|
||||
}, [navigate]);
|
||||
|
||||
// Test runner state management
|
||||
// Use the test runners store to get global state for all worktrees
|
||||
const testRunnersStore = useTestRunnersStore();
|
||||
@@ -914,6 +933,7 @@ export function WorktreePanel({
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
@@ -932,6 +952,9 @@ export function WorktreePanel({
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1154,6 +1177,7 @@ export function WorktreePanel({
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
@@ -1171,6 +1195,9 @@ export function WorktreePanel({
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
/>
|
||||
|
||||
{useWorktreesEnabled && (
|
||||
@@ -1257,6 +1284,7 @@ export function WorktreePanel({
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
@@ -1276,6 +1304,9 @@ export function WorktreePanel({
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1340,6 +1371,7 @@ export function WorktreePanel({
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
@@ -1359,6 +1391,9 @@ export function WorktreePanel({
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
terminalScripts={terminalScripts}
|
||||
onRunTerminalScript={handleRunTerminalScript}
|
||||
onEditScripts={handleEditScripts}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user