feat: Add PR review comments and resolution endpoints, improve prompt handling

This commit is contained in:
gsxdsm
2026-02-20 15:59:57 -08:00
parent 0e020f7e4a
commit aa345a50ac
46 changed files with 3650 additions and 309 deletions

View File

@@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog';
export { NewProjectModal } from './new-project-modal';
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
export { SandboxRiskDialog } from './sandbox-risk-dialog';
export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog';
export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog';
export { WorkspacePickerModal } from './workspace-picker-modal';

File diff suppressed because it is too large Load Diff

View File

@@ -103,7 +103,15 @@ export function ProjectSwitcher() {
};
const handleProjectClick = useCallback(
(project: Project) => {
async (project: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(project.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(project);
// Navigate to board view when switching projects
navigate({ to: '/board' });

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import {
Folder,
ChevronDown,
@@ -15,6 +16,8 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import { initializeProject } from '@/lib/project-init';
import type { Project } from '@/lib/electron';
import {
DropdownMenu,
DropdownMenuContent,
@@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
// Wrap setCurrentProject to ensure .automaker is initialized before switching
const setCurrentProjectWithInit = useCallback(
async (p: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(p.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(p);
},
[setCurrentProject]
);
const {
projectSearchQuery,
setProjectSearchQuery,
@@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({
currentProject,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
setCurrentProject: setCurrentProjectWithInit,
});
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
@@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme();
const handleSelectProject = useCallback(
async (p: Project) => {
await setCurrentProjectWithInit(p);
setIsProjectPickerOpen(false);
},
[setCurrentProjectWithInit, setIsProjectPickerOpen]
);
if (!sidebarOpen || projects.length === 0) {
return null;
}
@@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={(p) => {
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
onSelect={handleSelectProject}
/>
))}
</div>

View File

@@ -6,7 +6,7 @@ interface UseProjectPickerProps {
currentProject: Project | null;
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setCurrentProject: (project: Project) => void;
setCurrentProject: (project: Project) => void | Promise<void>;
}
export function useProjectPicker({
@@ -92,9 +92,9 @@ export function useProjectPicker({
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
const selectHighlightedProject = useCallback(async () => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
setCurrentProject(filteredProjects[selectedProjectIndex]);
await setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false);
}
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);

View File

@@ -25,7 +25,7 @@ export interface SortableProjectItemProps {
project: Project;
currentProjectId: string | undefined;
isHighlighted: boolean;
onSelect: (project: Project) => void;
onSelect: (project: Project) => void | Promise<void>;
}
export interface ThemeMenuItemProps {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { PointerEvent as ReactPointerEvent } from 'react';
import {
@@ -34,6 +34,10 @@ import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/ty
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import {
PRCommentResolutionDialog,
type PRCommentResolutionPRInfo,
} from '@/components/dialogs/pr-comment-resolution-dialog';
import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { resolveModelString } from '@automaker/model-resolver';
@@ -184,6 +188,9 @@ export function BoardView() {
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] =
useState<PRCommentResolutionPRInfo | null>(null);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null
);
@@ -429,6 +436,29 @@ export function BoardView() {
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Track the previous worktree path to detect worktree switches
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
// When the active worktree changes, invalidate feature queries to ensure
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
// Without this, cards that unmount when filtered out and remount when the user
// switches back may show stale or missing todo list data until the next polling cycle.
useEffect(() => {
// Skip the initial mount (prevWorktreePathRef starts as undefined)
if (prevWorktreePathRef.current === undefined) {
prevWorktreePathRef.current = currentWorktreePath;
return;
}
// Only invalidate when the worktree actually changed
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
prevWorktreePathRef.current = currentWorktreePath;
}, [currentWorktreePath, currentProject?.path, queryClient]);
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
@@ -922,26 +952,39 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation]
);
// Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback(
// Handler for managing PR comments - opens the PR Comment Resolution dialog
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
setPRCommentDialogPRInfo({
number: prInfo.number,
title: prInfo.title,
// Pass the worktree's branch so features are created on the correct worktree
headRefName: worktree.branch,
});
setShowPRCommentDialog(true);
}, []);
// Handler for auto-addressing PR comments - immediately creates and starts a feature task
const handleAutoAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
// Use a simple prompt that instructs the agent to read and address PR feedback
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
const prNumber = prInfo.number;
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
if (!prInfo.number) {
toast.error('Cannot address PR comments', {
description: 'No PR number available for this worktree.',
});
return;
}
const featureData = {
title: `Address PR #${prNumber} Review Comments`,
category: 'PR Review',
description,
title: `Address PR #${prInfo.number} Review Comments`,
category: 'Maintenance',
description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for PR feedback
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -1508,6 +1551,7 @@ export function BoardView() {
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onAutoAddressPRComments={handleAutoAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict}
@@ -1985,6 +2029,18 @@ export function BoardView() {
}}
/>
{/* PR Comment Resolution Dialog */}
{prCommentDialogPRInfo && (
<PRCommentResolutionDialog
open={showPRCommentDialog}
onOpenChange={(open) => {
setShowPRCommentDialog(open);
if (!open) setPRCommentDialogPRInfo(null);
}}
pr={prCommentDialogPRInfo}
/>
)}
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />

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,12 @@ 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;
const branchNameForApi = 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 +338,24 @@ 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;
// Strip the remote prefix from the resolved ref for the API call
// (e.g. "origin/main" → "main") since the backend expects the branch name only
const baseBranchForApi = 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 +706,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

@@ -40,6 +40,7 @@ import {
AlertTriangle,
XCircle,
CheckCircle,
Settings2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -54,6 +55,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 +104,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 +131,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 +175,7 @@ export function WorktreeActionsDropdown({
onCommit,
onCreatePR,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
onDeleteWorktree,
onStartDevServer,
@@ -184,6 +194,9 @@ export function WorktreeActionsDropdown({
onAbortOperation,
onContinueOperation,
hasInitScript,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -358,19 +371,16 @@ 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?.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 +585,52 @@ 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 */}
{!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"
>
<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">
<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 +865,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,80 +934,65 @@ 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 */}
{(worktree.hasChanges || onViewStashes) && (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - view changes (primary action) */}
{worktree.hasChanges ? (
<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>
) : (
<DropdownMenuItem
onClick={() => onViewStashes!(worktree)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</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>
{worktree.hasChanges && 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 && (
<TooltipWrapper
@@ -961,43 +1031,69 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Show PR info and Address Comments button if PR exists */}
{/* Show PR info with Address Comments in sub-menu 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>
</>
<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="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
{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={() => {
// 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" />
Manage PR Comments
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const prInfo: PRInfo = {
number: worktree.pr!.number,
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: '',
body: '',
comments: [],
reviewComments: [],
};
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 - only shown when port is confirmed detected */}
{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

@@ -90,8 +90,16 @@ export function useWorktrees({
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
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]
);
// fetchWorktrees for backward compatibility - now just triggers a refetch

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

View File

@@ -10,11 +10,13 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { toast } from 'sonner';
import { queryKeys } from '@/lib/query-keys';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { AddFeatureDialog } from './board-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { useModelOverride } from '@/components/shared';
import type {
@@ -34,15 +36,22 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
// Add Feature dialog state
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
const [createFeatureIssue, setCreateFeatureIssue] = useState<GitHubIssue | null>(null);
// Filter state
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
const { currentProject, getCurrentWorktree, worktreesByProject, defaultSkipTests } =
useAppStore();
const queryClient = useQueryClient();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
const isMobile = useIsMobile();
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
@@ -108,6 +117,118 @@ export function GitHubIssuesView() {
api.openExternalLink(url);
}, []);
// Build a prefilled description from a GitHub issue for the feature dialog
const buildIssueDescription = useCallback(
(issue: GitHubIssue) => {
const parts = [
`**From GitHub Issue #${issue.number}**`,
'',
issue.body || 'No description provided.',
];
// Include labels if present
if (issue.labels.length > 0) {
parts.push('', `**Labels:** ${issue.labels.map((l) => l.name).join(', ')}`);
}
// Include linked PRs info if present
if (issue.linkedPRs && issue.linkedPRs.length > 0) {
parts.push(
'',
'**Linked Pull Requests:**',
...issue.linkedPRs.map((pr) => `- #${pr.number}: ${pr.title} (${pr.state})`)
);
}
// Include cached validation analysis if available
const cached = cachedValidations.get(issue.number);
if (cached?.result) {
const validation = cached.result;
parts.push('', '---', '', '**AI Validation Analysis:**', validation.reasoning);
if (validation.suggestedFix) {
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
}
if (validation.relatedFiles?.length) {
parts.push('', '**Related Files:**', ...validation.relatedFiles.map((f) => `- \`${f}\``));
}
}
return parts.filter(Boolean).join('\n');
},
[cachedValidations]
);
// Open the Add Feature dialog with pre-filled data from a GitHub issue
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
setCreateFeatureIssue(issue);
setShowAddFeatureDialog(true);
}, []);
// Handle feature creation from the AddFeatureDialog
const handleAddFeatureFromIssue = useCallback(
async (featureData: {
title: string;
category: string;
description: string;
priority: number;
model: string;
thinkingLevel: string;
reasoningEffort: string;
skipTests: boolean;
branchName: string;
planningMode: string;
requirePlanApproval: boolean;
workMode: string;
}) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (api.features?.create) {
const feature = {
id: `issue-${createFeatureIssue?.number || 'new'}-${generateUUID()}`,
title: featureData.title,
description: featureData.description,
category: featureData.category,
status: 'backlog' as const,
passes: false,
priority: featureData.priority,
model: featureData.model,
thinkingLevel: featureData.thinkingLevel,
reasoningEffort: featureData.reasoningEffort,
skipTests: featureData.skipTests,
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
planningMode: featureData.planningMode,
requirePlanApproval: featureData.requirePlanApproval,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
toast.success(
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`
);
setShowAddFeatureDialog(false);
setCreateFeatureIssue(null);
} else {
toast.error(result.error || 'Failed to create feature');
}
}
} catch (err) {
logger.error('Create feature from issue error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
}
},
[currentProject?.path, currentBranch, queryClient, createFeatureIssue]
);
const handleConvertToTask = useCallback(
async (issue: GitHubIssue, validation: IssueValidationResult) => {
if (!currentProject?.path) {
@@ -185,11 +306,12 @@ export function GitHubIssuesView() {
return (
<div className="flex-1 flex overflow-hidden">
{/* Issues List */}
{/* Issues List - hidden on mobile when an issue is selected */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedIssue ? 'w-80' : 'flex-1'
selectedIssue ? 'w-80' : 'flex-1',
isMobile && selectedIssue && 'hidden'
)}
>
{/* Header */}
@@ -296,8 +418,10 @@ export function GitHubIssuesView() {
setPendingRevalidateOptions(options);
setShowRevalidateConfirm(true);
}}
onCreateFeature={handleCreateFeature}
formatDate={formatDate}
modelOverride={validationModelOverride}
isMobile={isMobile}
/>
)}
@@ -310,6 +434,30 @@ export function GitHubIssuesView() {
onConvertToTask={handleConvertToTask}
/>
{/* Add Feature Dialog - opened from issue detail panel */}
<AddFeatureDialog
open={showAddFeatureDialog}
onOpenChange={(open) => {
setShowAddFeatureDialog(open);
if (!open) {
setCreateFeatureIssue(null);
}
}}
onAdd={handleAddFeatureFromIssue}
categorySuggestions={['From GitHub']}
branchSuggestions={[]}
defaultSkipTests={defaultSkipTests}
defaultBranch={currentBranch}
currentBranch={currentBranch || undefined}
isMaximized={false}
projectPath={currentProject?.path}
prefilledTitle={createFeatureIssue?.title}
prefilledDescription={
createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined
}
prefilledCategory="From GitHub"
/>
{/* Revalidate Confirmation Dialog */}
<ConfirmDialog
open={showRevalidateConfirm}

View File

@@ -12,6 +12,8 @@ import {
MessageSquare,
ChevronDown,
ChevronUp,
Plus,
ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useState } from 'react';
@@ -34,8 +36,10 @@ export function IssueDetailPanel({
onOpenInGitHub,
onClose,
onShowRevalidateConfirm,
onCreateFeature,
formatDate,
modelOverride,
isMobile = false,
}: IssueDetailPanelProps) {
const isValidating = validatingIssues.has(issue.number);
const cached = cachedValidations.get(issue.number);
@@ -71,8 +75,13 @@ export function IssueDetailPanel({
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<Button variant="ghost" size="sm" onClick={onClose} className="shrink-0 -ml-1">
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 shrink-0" />
) : (
@@ -82,12 +91,12 @@ export function IssueDetailPanel({
#{issue.number} {issue.title}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
{(() => {
if (isValidating) {
return (
<Button variant="default" size="sm" loading>
Validating...
{isMobile ? '...' : 'Validating...'}
</Button>
);
}
@@ -97,7 +106,7 @@ export function IssueDetailPanel({
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
View Result
{!isMobile && 'View Result'}
</Button>
<Button
variant="ghost"
@@ -116,7 +125,7 @@ export function IssueDetailPanel({
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
View (stale)
{!isMobile && 'View (stale)'}
</Button>
<ModelOverrideTrigger
currentModelEntry={modelOverride.effectiveModelEntry}
@@ -133,7 +142,7 @@ export function IssueDetailPanel({
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
{!isMobile && 'Re-validate'}
</Button>
</>
);
@@ -156,23 +165,36 @@ export function IssueDetailPanel({
onClick={() => onValidateIssue(issue, getValidationOptions())}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
{!isMobile && 'Validate with AI'}
</Button>
</>
);
})()}
{!isMobile && (
<Button
variant="secondary"
size="sm"
onClick={() => onCreateFeature(issue)}
title="Create a new feature to address this issue"
>
<Plus className="h-4 w-4 mr-1" />
Create Feature
</Button>
)}
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
<ExternalLink className="h-4 w-4" />
{!isMobile && <span className="ml-1">Open in GitHub</span>}
</Button>
{!isMobile && (
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
{/* Title */}
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
@@ -344,8 +366,25 @@ export function IssueDetailPanel({
)}
</div>
{/* Create Feature CTA - shown on mobile since it's hidden from the header */}
{isMobile && (
<div className="mt-6 p-4 rounded-lg bg-primary/5 border border-primary/20">
<div className="flex items-center gap-2 mb-2">
<Plus className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Create Feature</span>
</div>
<p className="text-sm text-muted-foreground mb-3">
Create a new feature task to address this issue.
</p>
<Button variant="secondary" onClick={() => onCreateFeature(issue)}>
<Plus className="h-4 w-4 mr-2" />
Create Feature
</Button>
</div>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View comments, add reactions, and more on GitHub.
</p>

View File

@@ -138,6 +138,8 @@ export interface IssueDetailPanelProps {
onClose: () => void;
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
/** Called when user wants to create a feature to address this issue */
onCreateFeature: (issue: GitHubIssue) => void;
formatDate: (date: string) => string;
/** Model override state */
modelOverride: {
@@ -146,4 +148,6 @@ export interface IssueDetailPanelProps {
isOverridden: boolean;
setOverride: (entry: PhaseModelEntry | null) => void;
};
/** Whether the view is in mobile mode - shows back button and full-screen detail */
isMobile?: boolean;
}

View File

@@ -5,18 +5,41 @@
*/
import { useState, useCallback } from 'react';
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import {
GitPullRequest,
RefreshCw,
ExternalLink,
GitMerge,
X,
MessageSquare,
MoreHorizontal,
Zap,
ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useAppStore, type Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { useGitHubPRs } from '@/hooks/queries';
import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
import { PRCommentResolutionDialog } from '@/components/dialogs/pr-comment-resolution-dialog';
import { toast } from 'sonner';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function GitHubPRsView() {
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
const [commentDialogPR, setCommentDialogPR] = useState<GitHubPR | null>(null);
const { currentProject } = useAppStore();
const isMobile = useIsMobile();
const {
data,
@@ -38,6 +61,50 @@ export function GitHubPRsView() {
api.openExternalLink(url);
}, []);
const createFeature = useCreateFeature(currentProject?.path ?? '');
const handleAutoAddressComments = useCallback(
async (pr: GitHubPR) => {
if (!pr.number || !currentProject?.path) {
toast.error('Cannot address PR comments', {
description: 'No PR number or project available.',
});
return;
}
const featureId = `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const feature: Feature = {
id: featureId,
title: `Address PR #${pr.number} Review Comments`,
category: 'bug-fix',
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
steps: [],
status: 'in_progress',
model: 'opus',
thinkingLevel: 'none',
planningMode: 'skip',
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
};
try {
await createFeature.mutateAsync(feature);
// Start the feature immediately after creation
const api = getElectronAPI();
await api.features?.run(currentProject.path, featureId);
toast.success('Feature created and started', {
description: `Addressing review comments on PR #${pr.number}`,
});
} catch (error) {
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
[currentProject, createFeature]
);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
@@ -91,11 +158,12 @@ export function GitHubPRsView() {
return (
<div className="flex-1 flex overflow-hidden">
{/* PR List */}
{/* PR List - hidden on mobile when a PR is selected */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedPR ? 'w-80' : 'flex-1'
selectedPR ? 'w-80' : 'flex-1',
isMobile && selectedPR && 'hidden'
)}
>
{/* Header */}
@@ -140,6 +208,8 @@ export function GitHubPRsView() {
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
onManageComments={() => setCommentDialogPR(pr)}
onAutoAddressComments={() => handleAutoAddressComments(pr)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
@@ -158,6 +228,8 @@ export function GitHubPRsView() {
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
onManageComments={() => setCommentDialogPR(pr)}
onAutoAddressComments={() => handleAutoAddressComments(pr)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
@@ -173,8 +245,18 @@ export function GitHubPRsView() {
{selectedPR && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedPR(null)}
className="shrink-0 -ml-1"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{selectedPR.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
) : (
@@ -189,23 +271,31 @@ export function GitHubPRsView() {
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
{!isMobile && (
<Button variant="outline" size="sm" onClick={() => setCommentDialogPR(selectedPR)}>
<MessageSquare className="h-4 w-4 mr-1" />
Manage Comments
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedPR.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
<ExternalLink className="h-4 w-4" />
{!isMobile && <span className="ml-1">Open in GitHub</span>}
</Button>
{!isMobile && (
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* PR Detail Content */}
<div className="flex-1 overflow-auto p-6">
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
@@ -276,8 +366,29 @@ export function GitHubPRsView() {
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Review Comments CTA */}
<div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-center gap-2 mb-2">
<MessageSquare className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Review Comments</span>
</div>
<p className="text-sm text-muted-foreground mb-3">
Manage review comments individually or let AI address all feedback automatically.
</p>
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
<MessageSquare className="h-4 w-4 mr-2" />
Manage Review Comments
</Button>
<Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
<Zap className="h-4 w-4 mr-2" />
Address Review Comments
</Button>
</div>
</div>
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View code changes, comments, and reviews on GitHub.
</p>
@@ -289,6 +400,17 @@ export function GitHubPRsView() {
</div>
</div>
)}
{/* PR Comment Resolution Dialog */}
{commentDialogPR && (
<PRCommentResolutionDialog
open={!!commentDialogPR}
onOpenChange={(open) => {
if (!open) setCommentDialogPR(null);
}}
pr={commentDialogPR}
/>
)}
</div>
);
}
@@ -298,6 +420,8 @@ interface PRRowProps {
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
onManageComments: () => void;
onAutoAddressComments: () => void;
formatDate: (date: string) => string;
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
}
@@ -307,6 +431,8 @@ function PRRow({
isSelected,
onClick,
onOpenExternal,
onManageComments,
onAutoAddressComments,
formatDate,
getReviewStatus,
}: PRRowProps) {
@@ -378,17 +504,52 @@ function PRRow({
</div>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
{/* Actions dropdown menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="shrink-0 h-7 w-7 p-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onManageComments();
}}
className="text-xs text-blue-500 focus:text-blue-600"
>
<MessageSquare className="h-3.5 w-3.5 mr-2" />
Manage PR Comments
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onAutoAddressComments();
}}
className="text-xs text-blue-500 focus:text-blue-600"
>
<Zap className="h-3.5 w-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
className="text-xs"
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open in GitHub
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,594 @@
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Terminal,
Save,
RotateCcw,
Info,
X,
Play,
FlaskConical,
ScrollText,
Plus,
GripVertical,
Trash2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useProjectSettings } from '@/hooks/queries';
import { useUpdateProjectSettings } from '@/hooks/mutations';
import type { Project } from '@/lib/electron';
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
/** Preset dev server commands for quick selection */
const DEV_SERVER_PRESETS = [
{ label: 'npm run dev', command: 'npm run dev' },
{ label: 'yarn dev', command: 'yarn dev' },
{ label: 'pnpm dev', command: 'pnpm dev' },
{ label: 'bun dev', command: 'bun dev' },
{ label: 'npm start', command: 'npm start' },
{ label: 'cargo watch', command: 'cargo watch -x run' },
{ label: 'go run', command: 'go run .' },
] as const;
/** Preset test commands for quick selection */
const TEST_PRESETS = [
{ label: 'npm test', command: 'npm test' },
{ label: 'yarn test', command: 'yarn test' },
{ label: 'pnpm test', command: 'pnpm test' },
{ label: 'bun test', command: 'bun test' },
{ label: 'pytest', command: 'pytest' },
{ label: 'cargo test', command: 'cargo test' },
{ label: 'go test', command: 'go test ./...' },
] as const;
/** Preset scripts for quick addition */
const SCRIPT_PRESETS = [
{ name: 'Dev Server', command: 'npm run dev' },
{ name: 'Build', command: 'npm run build' },
{ name: 'Test', command: 'npm run test' },
{ name: 'Lint', command: 'npm run lint' },
{ name: 'Format', command: 'npm run format' },
{ name: 'Type Check', command: 'npm run typecheck' },
{ name: 'Start', command: 'npm start' },
{ name: 'Clean', command: 'npm run clean' },
] as const;
interface ScriptEntry {
id: string;
name: string;
command: string;
}
interface CommandsAndScriptsSectionProps {
project: Project;
}
/** Generate a unique ID for a new script */
function generateId(): string {
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSectionProps) {
// Fetch project settings using TanStack Query
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
// Mutation hook for updating project settings
const updateSettingsMutation = useUpdateProjectSettings(project.path);
// ── Commands state ──
const [devCommand, setDevCommand] = useState('');
const [originalDevCommand, setOriginalDevCommand] = useState('');
const [testCommand, setTestCommand] = useState('');
const [originalTestCommand, setOriginalTestCommand] = useState('');
// ── Scripts state ──
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
// Dragging state for scripts
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Reset local state when project changes
useEffect(() => {
setDevCommand('');
setOriginalDevCommand('');
setTestCommand('');
setOriginalTestCommand('');
setScripts([]);
setOriginalScripts([]);
}, [project.path]);
// Sync commands state when project settings load
useEffect(() => {
if (projectSettings) {
const dev = projectSettings.devCommand || '';
const test = projectSettings.testCommand || '';
setDevCommand(dev);
setOriginalDevCommand(dev);
setTestCommand(test);
setOriginalTestCommand(test);
}
}, [projectSettings]);
// Sync scripts state when project settings load
useEffect(() => {
if (projectSettings) {
const configured = projectSettings.terminalScripts;
const scriptList =
configured && configured.length > 0
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
setScripts(scriptList);
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
}
}, [projectSettings, project.path]);
// ── Change detection ──
const hasDevChanges = devCommand !== originalDevCommand;
const hasTestChanges = testCommand !== originalTestCommand;
const hasCommandChanges = hasDevChanges || hasTestChanges;
const hasScriptChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
const hasChanges = hasCommandChanges || hasScriptChanges;
const isSaving = updateSettingsMutation.isPending;
// ── Save all (commands + scripts) ──
const handleSave = useCallback(() => {
const normalizedDevCommand = devCommand.trim();
const normalizedTestCommand = testCommand.trim();
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
const normalizedScripts = validScripts.map((s) => ({
id: s.id,
name: s.name.trim(),
command: s.command.trim(),
}));
updateSettingsMutation.mutate(
{
devCommand: normalizedDevCommand || null,
testCommand: normalizedTestCommand || null,
terminalScripts: normalizedScripts,
},
{
onSuccess: () => {
setDevCommand(normalizedDevCommand);
setOriginalDevCommand(normalizedDevCommand);
setTestCommand(normalizedTestCommand);
setOriginalTestCommand(normalizedTestCommand);
setScripts(normalizedScripts);
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
},
}
);
}, [devCommand, testCommand, scripts, updateSettingsMutation]);
// ── Reset all ──
const handleReset = useCallback(() => {
setDevCommand(originalDevCommand);
setTestCommand(originalTestCommand);
setScripts(JSON.parse(JSON.stringify(originalScripts)));
}, [originalDevCommand, originalTestCommand, originalScripts]);
// ── Command handlers ──
const handleUseDevPreset = useCallback((command: string) => {
setDevCommand(command);
}, []);
const handleUseTestPreset = useCallback((command: string) => {
setTestCommand(command);
}, []);
const handleClearDev = useCallback(() => {
setDevCommand('');
}, []);
const handleClearTest = useCallback(() => {
setTestCommand('');
}, []);
// ── Script handlers ──
const handleAddScript = useCallback(() => {
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
}, []);
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
setScripts((prev) => [
...prev,
{ id: generateId(), name: preset.name, command: preset.command },
]);
}, []);
const handleRemoveScript = useCallback((index: number) => {
setScripts((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleUpdateScript = useCallback(
(index: number, field: 'name' | 'command', value: string) => {
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
},
[]
);
// Handle keyboard shortcuts (Enter to save)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && hasChanges && !isSaving) {
e.preventDefault();
handleSave();
}
},
[hasChanges, isSaving, handleSave]
);
// ── Drag and drop handlers for script reordering ──
const handleDragStart = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
setDragOverIndex(index);
},
[draggedIndex]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
setScripts((prev) => {
const newScripts = [...prev];
const [removed] = newScripts.splice(draggedIndex, 1);
newScripts.splice(dragOverIndex, 0, removed);
return newScripts;
});
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[draggedIndex, dragOverIndex]
);
const handleDragEnd = useCallback((_e: React.DragEvent) => {
setDraggedIndex(null);
setDragOverIndex(null);
}, []);
return (
<div className="space-y-6">
{/* ── Commands Card ── */}
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
data-testid="commands-section"
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Project Commands
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure custom commands for development and testing.
</p>
</div>
<div className="p-6 space-y-8">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : isError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
Failed to load project settings. Please try again.
</div>
) : (
<>
{/* Dev Server Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Play className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
{hasDevChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="dev-command"
value={devCommand}
onChange={(e) => setDevCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm run dev, yarn dev, cargo watch"
className="font-mono text-sm pr-8"
data-testid="dev-command-input"
/>
{devCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearDev}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear dev command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your package manager.
</p>
{/* Dev Presets */}
<div className="flex flex-wrap gap-1.5">
{DEV_SERVER_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseDevPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-border/30" />
{/* Test Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
{hasTestChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="test-command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm test, pytest, cargo test"
className="font-mono text-sm pr-8"
data-testid="test-command-input"
/>
{testCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearTest}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear test command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your project structure.
</p>
{/* Test Presets */}
<div className="flex flex-wrap gap-1.5">
{TEST_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseTestPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Auto-detection Info */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Auto-detection</p>
<p>
When no custom command is set, the system automatically detects your package
manager and test framework based on project files (package.json, Cargo.toml,
go.mod, etc.).
</p>
</div>
</div>
</>
)}
</div>
</div>
{/* ── Terminal Quick Scripts Card ── */}
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
data-testid="scripts-section"
>
{/* Header */}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<ScrollText className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Terminal Quick Scripts
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure quick-access scripts that appear in the terminal header dropdown. Click any
script to run it instantly.
</p>
</div>
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : isError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
Failed to load project settings. Please try again.
</div>
) : (
<>
{/* Scripts List */}
<div className="space-y-2">
{scripts.map((script, index) => (
<div
key={script.id}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
draggedIndex === index && 'opacity-50',
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
)}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e)}
onDragEnd={(e) => handleDragEnd(e)}
>
{/* Drag handle */}
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4" />
</div>
{/* Script name */}
<Input
value={script.name}
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Script name"
className="h-8 text-sm flex-[0.4] min-w-0"
/>
{/* Script command */}
<Input
value={script.command}
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Command to run"
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
/>
{/* Remove button */}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveScript(index)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
aria-label={`Remove ${script.name || 'script'}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
{scripts.length === 0 && (
<div className="text-center py-6 text-sm text-muted-foreground">
No scripts configured. Add some below or use a preset.
</div>
)}
</div>
{/* Add Script Button */}
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
<Plus className="w-3.5 h-3.5" />
Add Script
</Button>
{/* Divider */}
<div className="border-t border-border/30" />
{/* Presets */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
<div className="flex flex-wrap gap-1.5">
{SCRIPT_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleAddPreset(preset)}
className="text-xs font-mono h-7 px-2"
>
{preset.command}
</Button>
))}
</div>
</div>
{/* Info Box */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
<p>
These scripts appear in the terminal header as a dropdown menu (the{' '}
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
Clicking a script will type the command into the active terminal and press
Enter. Drag to reorder scripts.
</p>
</div>
</div>
</>
)}
</div>
</div>
{/* ── Shared Action Buttons ── */}
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</div>
);
}

View File

@@ -7,7 +7,6 @@ import {
Workflow,
Database,
Terminal,
ScrollText,
} from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
@@ -20,8 +19,7 @@ export interface ProjectNavigationItem {
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'commands', label: 'Commands', icon: Terminal },
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
{ id: 'commands-scripts', label: 'Commands & Scripts', icon: Terminal },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },

View File

@@ -6,6 +6,7 @@ export type ProjectSettingsViewId =
| 'worktrees'
| 'commands'
| 'scripts'
| 'commands-scripts'
| 'claude'
| 'data'
| 'danger';

View File

@@ -2,6 +2,8 @@ export { ProjectSettingsView } from './project-settings-view';
export { ProjectIdentitySection } from './project-identity-section';
export { ProjectThemeSection } from './project-theme-section';
export { WorktreePreferencesSection } from './worktree-preferences-section';
export { CommandsAndScriptsSection } from './commands-and-scripts-section';
// Legacy exports kept for backward compatibility
export { CommandsSection } from './commands-section';
export { TerminalScriptsSection } from './terminal-scripts-section';
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';

View File

@@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { CommandsSection } from './commands-section';
import { TerminalScriptsSection } from './terminal-scripts-section';
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
@@ -15,6 +14,8 @@ import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-fr
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
import { useProjectSettingsView } from './hooks/use-project-settings-view';
import type { Project as ElectronProject } from '@/lib/electron';
import { useSearch } from '@tanstack/react-router';
import type { ProjectSettingsViewId } from './hooks/use-project-settings-view';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024;
@@ -34,8 +35,18 @@ export function ProjectSettingsView() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// Read the optional section search param to support deep-linking to a specific section
const search = useSearch({ strict: false }) as { section?: ProjectSettingsViewId };
// Map legacy 'commands' and 'scripts' IDs to the combined 'commands-scripts' section
const resolvedSection: ProjectSettingsViewId | undefined =
search.section === 'commands' || search.section === 'scripts'
? 'commands-scripts'
: search.section;
// Use project settings view navigation hook
const { activeView, navigateTo } = useProjectSettingsView();
const { activeView, navigateTo } = useProjectSettingsView({
initialView: resolvedSection ?? 'identity',
});
// Mobile navigation state - default to showing on desktop, hidden on mobile
const [showNavigation, setShowNavigation] = useState(() => {
@@ -91,9 +102,9 @@ export function ProjectSettingsView() {
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'commands':
return <CommandsSection project={currentProject} />;
case 'scripts':
return <TerminalScriptsSection project={currentProject} />;
case 'commands-scripts':
return <CommandsAndScriptsSection project={currentProject} />;
case 'claude':
return <ProjectModelsSection project={currentProject} />;
case 'data':

View File

@@ -240,9 +240,17 @@ interface TerminalViewProps {
initialMode?: 'tab' | 'split';
/** Unique nonce to allow opening the same worktree multiple times */
nonce?: number;
/** Command to run automatically when the terminal is created (e.g., from scripts submenu) */
initialCommand?: string;
}
export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
export function TerminalView({
initialCwd,
initialBranch,
initialMode,
nonce,
initialCommand,
}: TerminalViewProps) {
const {
terminalState,
setTerminalUnlocked,
@@ -288,6 +296,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const isCreatingRef = useRef<boolean>(false);
const restoringProjectPathRef = useRef<string | null>(null);
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
// Per-session command overrides (e.g., from scripts submenu), takes priority over defaultRunScript
const [sessionCommandOverrides, setSessionCommandOverrides] = useState<Map<string, string>>(
new Map()
);
const [serverSessionInfo, setServerSessionInfo] = useState<{
current: number;
max: number;
@@ -618,8 +630,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
}
// Mark this session as new for running initial command
if (defaultRunScript) {
if (initialCommand || defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
// Store per-session command override if an explicit command was provided
if (initialCommand) {
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, initialCommand));
}
}
// Show success toast with branch name if provided
@@ -654,6 +670,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
initialCwd,
initialBranch,
initialMode,
initialCommand,
nonce,
status?.enabled,
status?.passwordRequired,
@@ -1059,7 +1076,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
// Create terminal in new tab
// customCwd: optional working directory (e.g., a specific worktree path)
// branchName: optional branch name to display in the terminal panel header
const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => {
// command: optional command to run when the terminal connects (e.g., from scripts menu)
const createTerminalInNewTab = async (
customCwd?: string,
branchName?: string,
command?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return;
}
@@ -1087,8 +1109,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const { addTerminalToTab } = useAppStore.getState();
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
// Mark this session as new for running initial command
if (defaultRunScript) {
if (command || defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
// Store per-session command override if an explicit command was provided
if (command) {
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, command));
}
}
// Refresh session count
fetchServerSettings();
@@ -1255,6 +1281,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
next.delete(sessionId);
return next;
});
// Clean up any per-session command override
setSessionCommandOverrides((prev) => {
const next = new Map(prev);
next.delete(sessionId);
return next;
});
}, []);
// Navigate between terminal panes with directional awareness
@@ -1387,6 +1419,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
// Only run command on new sessions (not restored ones)
const isNewSession = newSessionIds.has(content.sessionId);
// Per-session command override takes priority over defaultRunScript
const sessionCommand = sessionCommandOverrides.get(content.sessionId);
const runCommand = isNewSession ? sessionCommand || defaultRunScript : undefined;
return (
<TerminalErrorBoundary
key={`boundary-${content.sessionId}`}
@@ -1413,6 +1448,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
createTerminal('vertical', content.sessionId, cwd, branchName);
}}
onNewTab={createTerminalInNewTab}
onRunCommandInNewTab={(command: string) => {
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
createTerminalInNewTab(cwd, branch, command);
}}
onNavigateUp={() => navigateToTerminal('up')}
onNavigateDown={() => navigateToTerminal('down')}
onNavigateLeft={() => navigateToTerminal('left')}
@@ -1427,7 +1466,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
runCommandOnConnect={isNewSession ? defaultRunScript : undefined}
runCommandOnConnect={runCommand}
onCommandRan={() => handleCommandRan(content.sessionId)}
isMaximized={terminalState.maximizedSessionId === content.sessionId}
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
@@ -1971,6 +2010,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
}}
onNewTab={createTerminalInNewTab}
onRunCommandInNewTab={(command: string) => {
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
createTerminalInNewTab(cwd, branch, command);
}}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import {
X,
@@ -90,6 +91,7 @@ interface TerminalPanelProps {
onSplitHorizontal: () => void;
onSplitVertical: () => void;
onNewTab?: () => void;
onRunCommandInNewTab?: (command: string) => void; // Run a script command in a new terminal tab
onNavigateUp?: () => void; // Navigate to terminal pane above
onNavigateDown?: () => void; // Navigate to terminal pane below
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
@@ -120,6 +122,7 @@ export function TerminalPanel({
onSplitHorizontal,
onSplitVertical,
onNewTab,
onRunCommandInNewTab,
onNavigateUp,
onNavigateDown,
onNavigateLeft,
@@ -135,6 +138,7 @@ export function TerminalPanel({
onToggleMaximize,
branchName,
}: TerminalPanelProps) {
const navigate = useNavigate();
const terminalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
@@ -2071,7 +2075,11 @@ export function TerminalPanel({
{/* Quick scripts dropdown */}
<TerminalScriptsDropdown
onRunCommand={sendCommand}
onRunCommandInNewTab={onRunCommandInNewTab}
isConnected={connectionStatus === 'connected'}
onOpenSettings={() =>
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } })
}
/>
{/* Settings popover */}

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ScrollText, Play, Settings2 } from 'lucide-react';
import { ScrollText, Play, Settings2, SquareArrowOutUpRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -17,6 +17,8 @@ import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scri
interface TerminalScriptsDropdownProps {
/** Callback to send a command + newline to the terminal */
onRunCommand: (command: string) => void;
/** Callback to run a command in a new terminal tab */
onRunCommandInNewTab?: (command: string) => void;
/** Whether the terminal is connected and ready */
isConnected: boolean;
/** Optional callback to navigate to project settings scripts section */
@@ -25,11 +27,13 @@ interface TerminalScriptsDropdownProps {
/**
* Dropdown menu in the terminal header bar that provides quick-access
* to user-configured project scripts. Clicking a script inserts the
* command into the terminal and presses Enter.
* to user-configured project scripts. Each script is a split button:
* clicking the left side runs the command in the current terminal,
* clicking the "new tab" icon on the right runs it in a new tab.
*/
export function TerminalScriptsDropdown({
onRunCommand,
onRunCommandInNewTab,
isConnected,
onOpenSettings,
}: TerminalScriptsDropdownProps) {
@@ -53,6 +57,14 @@ export function TerminalScriptsDropdown({
[isConnected, onRunCommand]
);
const handleRunScriptInNewTab = useCallback(
(command: string) => {
if (!isConnected || !onRunCommandInNewTab) return;
onRunCommandInNewTab(command);
},
[isConnected, onRunCommandInNewTab]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -82,7 +94,7 @@ export function TerminalScriptsDropdown({
key={script.id}
onClick={() => handleRunScript(script.command)}
disabled={!isConnected}
className="gap-2"
className="gap-2 pr-1"
>
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
<div className="flex flex-col min-w-0 flex-1">
@@ -91,17 +103,43 @@ export function TerminalScriptsDropdown({
{script.command}
</span>
</div>
{onRunCommandInNewTab && (
<button
type="button"
className={cn(
'shrink-0 ml-1 p-1 rounded-sm border-l border-border',
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
'transition-colors',
!isConnected && 'pointer-events-none opacity-50'
)}
title="Run in new tab"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRunScriptInNewTab(script.command);
}}
onPointerDown={(e) => {
// Prevent the DropdownMenuItem from handling this pointer event
e.stopPropagation();
}}
onPointerUp={(e) => {
e.stopPropagation();
}}
>
<SquareArrowOutUpRight className="h-3 w-3" />
</button>
)}
</DropdownMenuItem>
))}
{onOpenSettings && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onOpenSettings} className="gap-2 text-muted-foreground">
<Settings2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm">Configure Scripts...</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onOpenSettings}
className="gap-2 text-muted-foreground"
disabled={!onOpenSettings}
>
<Settings2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm">Edit Commands & Scripts</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -62,6 +62,7 @@ export {
useValidateIssue,
useMarkValidationViewed,
useGetValidationStatus,
useResolveReviewThread,
} from './use-github-mutations';
// Ideation mutations

View File

@@ -135,6 +135,55 @@ export function useMarkValidationViewed(projectPath: string) {
});
}
/**
* Resolve or unresolve a PR review thread
*
* @param projectPath - Path to the project
* @param prNumber - PR number (for cache invalidation)
* @returns Mutation for resolving/unresolving a review thread
*
* @example
* ```tsx
* const resolveThread = useResolveReviewThread(projectPath, prNumber);
* resolveThread.mutate({ threadId: comment.threadId, resolve: true });
* ```
*/
export function useResolveReviewThread(projectPath: string, prNumber: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ threadId, resolve }: { threadId: string; resolve: boolean }) => {
const api = getElectronAPI();
if (!api.github?.resolveReviewThread) {
throw new Error('Resolve review thread API not available');
}
const result = await api.github.resolveReviewThread(projectPath, threadId, resolve);
if (!result.success) {
throw new Error(result.error || 'Failed to resolve review thread');
}
return { isResolved: result.isResolved ?? resolve };
},
onSuccess: (_, variables) => {
const action = variables.resolve ? 'resolved' : 'unresolved';
toast.success(`Comment ${action}`, {
description: `The review thread has been ${action} on GitHub`,
});
// Invalidate the PR review comments cache to reflect updated resolved status
queryClient.invalidateQueries({
queryKey: queryKeys.github.prReviewComments(projectPath, prNumber),
});
},
onError: (error) => {
toast.error('Failed to update comment', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
});
}
/**
* Get running validation status
*

View File

@@ -20,6 +20,7 @@ export {
useGitHubValidations,
useGitHubRemote,
useGitHubIssueComments,
useGitHubPRReviewComments,
} from './use-github';
// Usage

View File

@@ -8,7 +8,13 @@ import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { GitHubIssue, GitHubPR, GitHubComment, StoredValidation } from '@/lib/electron';
import type {
GitHubIssue,
GitHubPR,
GitHubComment,
PRReviewComment,
StoredValidation,
} from '@/lib/electron';
interface GitHubIssuesResult {
openIssues: GitHubIssue[];
@@ -197,3 +203,45 @@ export function useGitHubIssueComments(
staleTime: STALE_TIMES.GITHUB,
});
}
/**
* Fetch review comments for a GitHub PR
*
* Fetches both regular PR comments and inline code review comments
* with file path and line context for each.
*
* @param projectPath - Path to the project
* @param prNumber - PR number
* @returns Query result with review comments
*
* @example
* ```tsx
* const { data, isLoading } = useGitHubPRReviewComments(projectPath, prNumber);
* const comments = data?.comments ?? [];
* ```
*/
export function useGitHubPRReviewComments(
projectPath: string | undefined,
prNumber: number | undefined
) {
return useQuery({
queryKey: queryKeys.github.prReviewComments(projectPath ?? '', prNumber ?? 0),
queryFn: async (): Promise<{ comments: PRReviewComment[]; totalCount: number }> => {
if (!projectPath || !prNumber) throw new Error('Missing project path or PR number');
const api = getElectronAPI();
if (!api.github) {
throw new Error('GitHub API not available');
}
const result = await api.github.getPRReviewComments(projectPath, prNumber);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch PR review comments');
}
return {
comments: (result.comments ?? []) as PRReviewComment[],
totalCount: result.totalCount ?? 0,
};
},
enabled: !!projectPath && !!prNumber,
staleTime: STALE_TIMES.GITHUB,
});
}

View File

@@ -313,6 +313,34 @@ export interface GitHubRemoteStatus {
repo: string | null;
}
/** A review comment on a pull request (inline code comment or general PR comment) */
export interface PRReviewComment {
id: string;
author: string;
avatarUrl?: string;
body: string;
/** File path for inline review comments */
path?: string;
/** Line number for inline review comments */
line?: number;
createdAt: string;
updatedAt?: string;
/** Whether this is an inline code review comment (vs general PR comment) */
isReviewComment: boolean;
/** Whether this comment is outdated (code has changed since) */
isOutdated?: boolean;
/** Whether the review thread containing this comment has been resolved */
isResolved?: boolean;
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
threadId?: string;
/** The diff hunk context for the comment */
diffHunk?: string;
/** The side of the diff (LEFT or RIGHT) */
side?: string;
/** The commit ID the comment was made on */
commitId?: string;
}
export interface GitHubAPI {
checkRemote: (projectPath: string) => Promise<{
success: boolean;
@@ -389,6 +417,26 @@ export interface GitHubAPI {
endCursor?: string;
error?: string;
}>;
/** Fetch review comments for a specific pull request */
getPRReviewComments: (
projectPath: string,
prNumber: number
) => Promise<{
success: boolean;
comments?: PRReviewComment[];
totalCount?: number;
error?: string;
}>;
/** Resolve or unresolve a PR review thread */
resolveReviewThread: (
projectPath: string,
threadId: string,
resolve: boolean
) => Promise<{
success: boolean;
isResolved?: boolean;
error?: string;
}>;
}
// Spec Regeneration types
@@ -3980,6 +4028,21 @@ function createMockGitHubAPI(): GitHubAPI {
hasNextPage: false,
};
},
getPRReviewComments: async (projectPath: string, prNumber: number) => {
console.log('[Mock] Getting PR review comments:', { projectPath, prNumber });
return {
success: true,
comments: [],
totalCount: 0,
};
},
resolveReviewThread: async (projectPath: string, threadId: string, resolve: boolean) => {
console.log('[Mock] Resolving review thread:', { projectPath, threadId, resolve });
return {
success: true,
isResolved: resolve,
};
},
};
}

View File

@@ -2474,6 +2474,10 @@ export class HttpApiClient implements ElectronAPI {
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
getPRReviewComments: (projectPath: string, prNumber: number) =>
this.post('/api/github/pr-review-comments', { projectPath, prNumber }),
resolveReviewThread: (projectPath: string, threadId: string, resolve: boolean) =>
this.post('/api/github/resolve-pr-comment', { projectPath, threadId, resolve }),
};
// Workspace API

View File

@@ -70,6 +70,9 @@ export const queryKeys = {
/** Issue comments */
issueComments: (projectPath: string, issueNumber: number) =>
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
/** PR review comments */
prReviewComments: (projectPath: string, prNumber: number) =>
['github', 'prs', projectPath, prNumber, 'review-comments'] as const,
/** Remote info */
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
},

View File

@@ -1,6 +1,16 @@
import { createFileRoute } from '@tanstack/react-router';
import { ProjectSettingsView } from '@/components/views/project-settings-view';
import type { ProjectSettingsViewId } from '@/components/views/project-settings-view/hooks/use-project-settings-view';
interface ProjectSettingsSearchParams {
section?: ProjectSettingsViewId;
}
export const Route = createFileRoute('/project-settings')({
component: ProjectSettingsView,
validateSearch: (search: Record<string, unknown>): ProjectSettingsSearchParams => {
return {
section: search.section as ProjectSettingsViewId | undefined,
};
},
});

View File

@@ -6,6 +6,14 @@ export const Route = createLazyFileRoute('/terminal')({
});
function RouteComponent() {
const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' });
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
const { cwd, branch, mode, nonce, command } = useSearch({ from: '/terminal' });
return (
<TerminalView
initialCwd={cwd}
initialBranch={branch}
initialMode={mode}
nonce={nonce}
initialCommand={command}
/>
);
}

View File

@@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({
branch: z.string().optional(),
mode: z.enum(['tab', 'split']).optional(),
nonce: z.coerce.number().optional(),
command: z.string().optional(),
});
// Component is lazy-loaded via terminal.lazy.tsx for code splitting