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:
gsxdsm
2026-02-20 21:34:40 -08:00
committed by GitHub
parent 0e020f7e4a
commit c81ea768a7
60 changed files with 4568 additions and 681 deletions

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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}`}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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]
);

View File

@@ -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

View File

@@ -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);

View File

@@ -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 */

View File

@@ -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}
/>
);
})}