mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Fix concurrency limits and remote branch fetching issues (#788)
* Changes from fix/bug-fixes * feat: Refactor worktree iteration and improve error logging across services * feat: Extract URL/port patterns to module level and fix abort condition * fix: Improve IPv6 loopback handling, select component layout, and terminal UI * feat: Add thinking level defaults and adjust list row padding * Update apps/ui/src/store/app-store.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit * feat: Add tracked remote detection to pull dialog flow * feat: Add merge state tracking to git operations * feat: Improve merge detection and add post-merge action preferences * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Pass merge detection info to stash reapplication and handle merge state consistently * fix: Call onPulled callback in merge handlers and add validation checks --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Minus,
|
||||
@@ -20,7 +21,7 @@ import { Button } from './button';
|
||||
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import type { FileStatus, MergeStateInfo } from '@/types/electron';
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
projectPath: string;
|
||||
@@ -318,6 +319,86 @@ function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MergeBadge({ mergeType }: { mergeType?: string }) {
|
||||
if (!mergeType) return null;
|
||||
|
||||
const label = (() => {
|
||||
switch (mergeType) {
|
||||
case 'both-modified':
|
||||
return 'Both Modified';
|
||||
case 'added-by-us':
|
||||
return 'Added by Us';
|
||||
case 'added-by-them':
|
||||
return 'Added by Them';
|
||||
case 'deleted-by-us':
|
||||
return 'Deleted by Us';
|
||||
case 'deleted-by-them':
|
||||
return 'Deleted by Them';
|
||||
case 'both-added':
|
||||
return 'Both Added';
|
||||
case 'both-deleted':
|
||||
return 'Both Deleted';
|
||||
case 'merged':
|
||||
return 'Merged';
|
||||
default:
|
||||
return 'Merge';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-1">
|
||||
<GitMerge className="w-2.5 h-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
|
||||
// Completed merge commit (HEAD is a merge)
|
||||
if (mergeState.isMergeCommit && !mergeState.isMerging) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-purple-400">Merge commit</span>
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— {mergeState.mergeAffectedFiles.length} file
|
||||
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} changed in merge
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// In-progress merge/rebase/cherry-pick
|
||||
const operationLabel =
|
||||
mergeState.mergeOperationType === 'cherry-pick'
|
||||
? 'Cherry-pick'
|
||||
: mergeState.mergeOperationType === 'rebase'
|
||||
? 'Rebase'
|
||||
: 'Merge';
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-purple-400">{operationLabel} in progress</span>
|
||||
{mergeState.conflictFiles.length > 0 ? (
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— {mergeState.conflictFiles.length} file
|
||||
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
|
||||
</span>
|
||||
) : mergeState.isCleanMerge ? (
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— Clean merge, {mergeState.mergeAffectedFiles.length} file
|
||||
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffSection({
|
||||
fileDiff,
|
||||
isExpanded,
|
||||
@@ -348,9 +429,21 @@ function FileDiffSection({
|
||||
|
||||
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
|
||||
|
||||
const isMergeFile = fileStatus?.isMergeAffected;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="w-full px-3 py-2 flex flex-col gap-1 text-left bg-card hover:bg-accent/50 transition-colors sm:flex-row sm:items-center sm:gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'border rounded-lg overflow-hidden',
|
||||
isMergeFile ? 'border-purple-500/40' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full px-3 py-2 flex flex-col gap-1 text-left transition-colors sm:flex-row sm:items-center sm:gap-2',
|
||||
isMergeFile ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{/* File name row */}
|
||||
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
|
||||
{isExpanded ? (
|
||||
@@ -358,7 +451,9 @@ function FileDiffSection({
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{fileStatus ? (
|
||||
{isMergeFile ? (
|
||||
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
|
||||
) : fileStatus ? (
|
||||
getFileIcon(fileStatus.status)
|
||||
) : (
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
@@ -370,6 +465,7 @@ function FileDiffSection({
|
||||
</button>
|
||||
{/* Indicators & staging row */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
|
||||
{fileStatus?.isMergeAffected && <MergeBadge mergeType={fileStatus.mergeType} />}
|
||||
{enableStaging && stagingState && <StagingBadge state={stagingState} />}
|
||||
{fileDiff.isNew && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||
@@ -483,9 +579,10 @@ export function GitDiffPanel({
|
||||
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
|
||||
const queryError = useWorktrees ? worktreeError : gitError;
|
||||
|
||||
// Extract files and diff content from the data
|
||||
// Extract files, diff content, and merge state from the data
|
||||
const files: FileStatus[] = diffsData?.files ?? [];
|
||||
const diffContent = diffsData?.diff ?? '';
|
||||
const mergeState: MergeStateInfo | undefined = diffsData?.mergeState;
|
||||
const error = queryError
|
||||
? queryError instanceof Error
|
||||
? queryError.message
|
||||
@@ -495,8 +592,6 @@ export function GitDiffPanel({
|
||||
// Refetch function
|
||||
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
// Build a map from file path to FileStatus for quick lookup
|
||||
const fileStatusMap = useMemo(() => {
|
||||
const map = new Map<string, FileStatus>();
|
||||
@@ -506,6 +601,24 @@ export function GitDiffPanel({
|
||||
return map;
|
||||
}, [files]);
|
||||
|
||||
const parsedDiffs = useMemo(() => {
|
||||
const diffs = parseDiff(diffContent);
|
||||
// Sort: merge-affected files first, then preserve original order
|
||||
if (mergeState?.isMerging || mergeState?.isMergeCommit) {
|
||||
const mergeSet = new Set(mergeState.mergeAffectedFiles);
|
||||
diffs.sort((a, b) => {
|
||||
const aIsMerge =
|
||||
mergeSet.has(a.filePath) || (fileStatusMap.get(a.filePath)?.isMergeAffected ?? false);
|
||||
const bIsMerge =
|
||||
mergeSet.has(b.filePath) || (fileStatusMap.get(b.filePath)?.isMergeAffected ?? false);
|
||||
if (aIsMerge && !bIsMerge) return -1;
|
||||
if (!aIsMerge && bIsMerge) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return diffs;
|
||||
}, [diffContent, mergeState, fileStatusMap]);
|
||||
|
||||
const toggleFile = (filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -682,6 +795,18 @@ export function GitDiffPanel({
|
||||
);
|
||||
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
|
||||
|
||||
// Compute merge summary
|
||||
const mergeSummary = useMemo(() => {
|
||||
const mergeFiles = files.filter((f) => f.isMergeAffected);
|
||||
if (mergeFiles.length === 0) return null;
|
||||
return {
|
||||
total: mergeFiles.length,
|
||||
conflicted: mergeFiles.filter(
|
||||
(f) => f.mergeType === 'both-modified' || f.mergeType === 'both-added'
|
||||
).length,
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
// Compute staging summary
|
||||
const stagingSummary = useMemo(() => {
|
||||
if (!enableStaging) return null;
|
||||
@@ -776,6 +901,11 @@ export function GitDiffPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Merge state banner */}
|
||||
{(mergeState?.isMerging || mergeState?.isMergeCommit) && (
|
||||
<MergeStateBanner mergeState={mergeState} />
|
||||
)}
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="p-4 pb-2 border-b border-border-glass">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -799,7 +929,7 @@ export function GitDiffPanel({
|
||||
{} as Record<string, { count: number; statusText: string; files: string[] }>
|
||||
);
|
||||
|
||||
return Object.entries(statusGroups).map(([status, group]) => (
|
||||
const groups = Object.entries(statusGroups).map(([status, group]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center gap-1.5"
|
||||
@@ -817,6 +947,24 @@ export function GitDiffPanel({
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
// Add merge group indicator if merge files exist
|
||||
if (mergeSummary) {
|
||||
groups.unshift(
|
||||
<div
|
||||
key="merge"
|
||||
className="flex items-center gap-1.5"
|
||||
data-testid="git-status-group-merge"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-xs px-1.5 py-0.5 rounded border font-medium bg-purple-500/20 text-purple-400 border-purple-500/30">
|
||||
{mergeSummary.total} Merge
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return groups;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
@@ -907,7 +1055,7 @@ export function GitDiffPanel({
|
||||
fileDiff={fileDiff}
|
||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined}
|
||||
fileStatus={fileStatusMap.get(fileDiff.filePath)}
|
||||
enableStaging={enableStaging}
|
||||
onStage={enableStaging ? handleStageFile : undefined}
|
||||
onUnstage={enableStaging ? handleUnstageFile : undefined}
|
||||
@@ -919,15 +1067,28 @@ export function GitDiffPanel({
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => {
|
||||
const stagingState = getStagingState(file);
|
||||
const isFileMerge = file.isMergeAffected;
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
className={cn(
|
||||
'border rounded-lg overflow-hidden',
|
||||
isFileMerge ? 'border-purple-500/40' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<div className="w-full px-3 py-2 flex flex-col gap-1 text-left bg-card sm:flex-row sm:items-center sm:gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full px-3 py-2 flex flex-col gap-1 text-left sm:flex-row sm:items-center sm:gap-2',
|
||||
isFileMerge ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card'
|
||||
)}
|
||||
>
|
||||
{/* File name row */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{getFileIcon(file.status)}
|
||||
{isFileMerge ? (
|
||||
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
|
||||
) : (
|
||||
getFileIcon(file.status)
|
||||
)}
|
||||
<TruncatedFilePath
|
||||
path={file.path}
|
||||
className="flex-1 text-sm font-mono text-foreground"
|
||||
@@ -935,6 +1096,7 @@ export function GitDiffPanel({
|
||||
</div>
|
||||
{/* Indicators & staging row */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
|
||||
{isFileMerge && <MergeBadge mergeType={file.mergeType} />}
|
||||
{enableStaging && <StagingBadge state={stagingState} />}
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
|
||||
</span>
|
||||
|
||||
{description ? (
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex flex-col items-start w-full min-w-0">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description}
|
||||
</div>
|
||||
|
||||
@@ -215,7 +215,7 @@ function TestLogsPanelContent({
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FlaskConical className="w-4 h-4 text-primary" />
|
||||
|
||||
@@ -375,10 +375,20 @@ export function BoardView() {
|
||||
return specificTargetCollisions;
|
||||
}
|
||||
|
||||
// Priority 2: Columns
|
||||
const columnCollisions = pointerCollisions.filter((collision: Collision) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
// Priority 2: Columns (including column headers and pipeline columns)
|
||||
const columnCollisions = pointerCollisions.filter((collision: Collision) => {
|
||||
const colId = String(collision.id);
|
||||
// Direct column ID match (e.g. 'backlog', 'in_progress')
|
||||
if (COLUMNS.some((col) => col.id === colId)) return true;
|
||||
// Column header droppable (e.g. 'column-header-backlog')
|
||||
if (colId.startsWith('column-header-')) {
|
||||
const baseId = colId.replace('column-header-', '');
|
||||
return COLUMNS.some((col) => col.id === baseId) || baseId.startsWith('pipeline_');
|
||||
}
|
||||
// Pipeline column IDs (e.g. 'pipeline_tests')
|
||||
if (colId.startsWith('pipeline_')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we found a column collision, use that
|
||||
if (columnCollisions.length > 0) {
|
||||
@@ -1426,13 +1436,12 @@ export function BoardView() {
|
||||
},
|
||||
});
|
||||
|
||||
// Also update backend if auto mode is running
|
||||
// Also update backend if auto mode is running.
|
||||
// Use restartWithConcurrency to avoid toggle flickering - it restarts
|
||||
// the backend without toggling isRunning off/on in the UI.
|
||||
if (autoMode.isRunning) {
|
||||
// Restart auto mode with new concurrency (backend will handle this)
|
||||
autoMode.stop().then(() => {
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
|
||||
});
|
||||
autoMode.restartWithConcurrency().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
interface CardActionsProps {
|
||||
feature: Feature;
|
||||
isCurrentAutoTask: boolean;
|
||||
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
|
||||
isRunningTask?: boolean;
|
||||
hasContext?: boolean;
|
||||
shortcutKey?: string;
|
||||
isSelectionMode?: boolean;
|
||||
@@ -36,6 +38,7 @@ interface CardActionsProps {
|
||||
export const CardActions = memo(function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
isRunningTask = false,
|
||||
hasContext: _hasContext,
|
||||
shortcutKey,
|
||||
isSelectionMode = false,
|
||||
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{/* Running task with stale status: feature is tracked as running but status hasn't updated yet.
|
||||
Show Logs/Stop controls instead of Make to avoid confusing UI. */}
|
||||
{!isCurrentAutoTask &&
|
||||
isRunningTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready') && (
|
||||
<>
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
{shortcutKey && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||
data-testid={`shortcut-key-${feature.id}`}
|
||||
>
|
||||
{shortcutKey}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{onForceStop && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForceStop();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`force-stop-${feature.id}`}
|
||||
>
|
||||
<StopCircle className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask &&
|
||||
!isRunningTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready') && (
|
||||
|
||||
@@ -114,15 +114,27 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
currentProject: state.currentProject,
|
||||
}))
|
||||
);
|
||||
// A card should only display as "actively running" if it's both in the
|
||||
// runningAutoTasks list AND in an execution-compatible status. Cards in resting
|
||||
// states (backlog, ready, waiting_approval, verified, completed) should never
|
||||
// show running controls, even if they appear in runningAutoTasks due to stale
|
||||
// state (e.g., after a server restart that reconciled features back to backlog).
|
||||
// A card should display as "actively running" if it's in the runningAutoTasks list
|
||||
// AND in an execution-compatible status. However, there's a race window where a feature
|
||||
// is tracked as running (in runningAutoTasks) but its disk/UI status hasn't caught up yet
|
||||
// (still 'backlog', 'ready', or 'interrupted'). In this case, we still want to show
|
||||
// running controls (Logs/Stop) and animated border, but not the full "actively running"
|
||||
// state that gates all UI behavior.
|
||||
const isInExecutionState =
|
||||
feature.status === 'in_progress' ||
|
||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState;
|
||||
// isRunningWithStaleStatus: feature is tracked as running but status hasn't updated yet.
|
||||
// This happens during the timing gap between when the server starts a feature and when
|
||||
// the UI receives the status update. Show running UI to prevent "Make" button flash.
|
||||
const isRunningWithStaleStatus =
|
||||
!!isCurrentAutoTask &&
|
||||
!isInExecutionState &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted');
|
||||
// Show running visual treatment for both fully confirmed and stale-status running tasks
|
||||
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -135,6 +147,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const isDraggable =
|
||||
!isSelectionMode &&
|
||||
!isRunningWithStaleStatus &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready' ||
|
||||
@@ -198,13 +211,13 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
'kanban-card-content h-full relative',
|
||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
// Disable hover translate for running cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isActivelyRunning &&
|
||||
!showRunningVisuals &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isActivelyRunning &&
|
||||
!showRunningVisuals &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
@@ -221,7 +234,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
style={isActivelyRunning ? undefined : cardStyle}
|
||||
style={showRunningVisuals ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
||||
onClick={handleCardClick}
|
||||
@@ -290,6 +303,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
isRunningTask={!!isCurrentAutoTask}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -316,7 +330,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
className={wrapperClasses}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
>
|
||||
{isActivelyRunning ? (
|
||||
{showRunningVisuals ? (
|
||||
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
||||
) : (
|
||||
renderCardContent()
|
||||
|
||||
@@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
contentStyle,
|
||||
disableItemSpacing = false,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
const { setNodeRef, isOver: isColumnOver } = useDroppable({ id });
|
||||
// Also make the header explicitly a drop target so dragging to the top of the column works
|
||||
const { setNodeRef: setHeaderDropRef, isOver: isHeaderOver } = useDroppable({
|
||||
id: `column-header-${id}`,
|
||||
});
|
||||
const isOver = isColumnOver || isHeaderOver;
|
||||
|
||||
// Use inline style for width if provided, otherwise use default w-72
|
||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||
@@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
|
||||
{/* Column Header */}
|
||||
{/* Column Header - also registered as a drop target so dragging to the header area works */}
|
||||
<div
|
||||
ref={setHeaderDropRef}
|
||||
className={cn(
|
||||
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
||||
showBorder && 'border-b border-border/40'
|
||||
|
||||
@@ -209,15 +209,22 @@ export const ListRow = memo(function ListRow({
|
||||
blockingDependencies = [],
|
||||
className,
|
||||
}: ListRowProps) {
|
||||
// A row should only display as "actively running" if it's both in the
|
||||
// runningAutoTasks list AND in an execution-compatible status. Features in resting
|
||||
// states (backlog, ready, waiting_approval, verified, completed) should never
|
||||
// show running controls, even if they appear in runningAutoTasks due to stale
|
||||
// state (e.g., after a server restart that reconciled features back to backlog).
|
||||
// A row should display as "actively running" if it's in the runningAutoTasks list
|
||||
// AND in an execution-compatible status. However, there's a race window where a feature
|
||||
// is tracked as running but its status hasn't caught up yet (still 'backlog', 'ready',
|
||||
// or 'interrupted'). We handle this with isRunningWithStaleStatus.
|
||||
const isInExecutionState =
|
||||
feature.status === 'in_progress' ||
|
||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||
const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
|
||||
// Feature is tracked as running but status hasn't updated yet - show running UI
|
||||
const isRunningWithStaleStatus =
|
||||
isCurrentAutoTask &&
|
||||
!isInExecutionState &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted');
|
||||
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -268,7 +275,7 @@ export const ListRow = memo(function ListRow({
|
||||
>
|
||||
{/* Checkbox column */}
|
||||
{showCheckbox && (
|
||||
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
|
||||
<div role="cell" className="flex items-center justify-center w-10 px-2 py-2 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
@@ -287,7 +294,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-3 pr-0 py-3 gap-0',
|
||||
'flex items-center pl-3 pr-0 py-2 gap-0',
|
||||
getColumnWidth('title'),
|
||||
getColumnAlign('title')
|
||||
)}
|
||||
@@ -296,7 +303,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium truncate',
|
||||
'text-sm font-medium truncate',
|
||||
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
|
||||
)}
|
||||
title={feature.title || feature.description}
|
||||
@@ -325,7 +332,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-0 pr-3 py-3 shrink-0',
|
||||
'flex items-center pl-0 pr-3 py-2 shrink-0',
|
||||
getColumnWidth('priority'),
|
||||
getColumnAlign('priority')
|
||||
)}
|
||||
@@ -358,14 +365,19 @@ export const ListRow = memo(function ListRow({
|
||||
</div>
|
||||
|
||||
{/* Actions column */}
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} />
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-2 w-[80px] shrink-0">
|
||||
<RowActions
|
||||
feature={feature}
|
||||
handlers={handlers}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
isRunningTask={!!isCurrentAutoTask}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Wrap with animated border for currently running auto task
|
||||
if (isActivelyRunning) {
|
||||
// Wrap with animated border for currently running auto task (including stale status)
|
||||
if (showRunningVisuals) {
|
||||
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface RowActionsProps {
|
||||
handlers: RowActionHandlers;
|
||||
/** Whether this feature is the current auto task (agent is running) */
|
||||
isCurrentAutoTask?: boolean;
|
||||
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
|
||||
isRunningTask?: boolean;
|
||||
/** Whether the dropdown menu is open */
|
||||
isOpen?: boolean;
|
||||
/** Callback when the dropdown open state changes */
|
||||
@@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({
|
||||
function getPrimaryAction(
|
||||
feature: Feature,
|
||||
handlers: RowActionHandlers,
|
||||
isCurrentAutoTask: boolean
|
||||
isCurrentAutoTask: boolean,
|
||||
isRunningTask: boolean = false
|
||||
): {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
@@ -135,6 +138,24 @@ function getPrimaryAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Running task with stale status - show stop instead of Make
|
||||
// This handles the race window where the feature is tracked as running
|
||||
// but status hasn't updated to in_progress yet
|
||||
if (
|
||||
isRunningTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted') &&
|
||||
handlers.onForceStop
|
||||
) {
|
||||
return {
|
||||
icon: StopCircle,
|
||||
label: 'Stop',
|
||||
onClick: handlers.onForceStop,
|
||||
variant: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
// Backlog - implement is primary
|
||||
if (feature.status === 'backlog' && handlers.onImplement) {
|
||||
return {
|
||||
@@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({
|
||||
feature,
|
||||
handlers,
|
||||
isCurrentAutoTask = false,
|
||||
isRunningTask = false,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
@@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
|
||||
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask);
|
||||
const secondaryActions = getSecondaryActions(feature, handlers);
|
||||
|
||||
// Helper to close menu after action
|
||||
@@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({
|
||||
)}
|
||||
|
||||
{/* Backlog actions */}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
{!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && (
|
||||
<>
|
||||
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||
{feature.planSpec?.content && handlers.onViewPlan && (
|
||||
|
||||
@@ -493,7 +493,7 @@ export function CherryPickDialog({
|
||||
if (step === 'select-commits') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cherry className="w-5 h-5 text-foreground" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
GitCommit,
|
||||
GitMerge,
|
||||
Sparkles,
|
||||
FilePlus,
|
||||
FileX,
|
||||
@@ -36,7 +37,7 @@ import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import type { FileStatus, MergeStateInfo } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface RemoteInfo {
|
||||
@@ -116,6 +117,27 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getMergeTypeLabel = (mergeType?: string) => {
|
||||
switch (mergeType) {
|
||||
case 'both-modified':
|
||||
return 'Both Modified';
|
||||
case 'added-by-us':
|
||||
return 'Added by Us';
|
||||
case 'added-by-them':
|
||||
return 'Added by Them';
|
||||
case 'deleted-by-us':
|
||||
return 'Deleted by Us';
|
||||
case 'deleted-by-them':
|
||||
return 'Deleted by Them';
|
||||
case 'both-added':
|
||||
return 'Both Added';
|
||||
case 'both-deleted':
|
||||
return 'Both Deleted';
|
||||
default:
|
||||
return 'Merge';
|
||||
}
|
||||
};
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
@@ -190,6 +212,7 @@ export function CommitWorktreeDialog({
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
|
||||
|
||||
// Push after commit state
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
@@ -274,6 +297,7 @@ export function CommitWorktreeDialog({
|
||||
setDiffContent('');
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
setMergeState(undefined);
|
||||
// Reset push state
|
||||
setPushAfterCommit(false);
|
||||
setRemotes([]);
|
||||
@@ -292,8 +316,20 @@ export function CommitWorktreeDialog({
|
||||
const result = await api.git.getDiffs(worktree.path);
|
||||
if (result.success) {
|
||||
const fileList = result.files ?? [];
|
||||
// Sort merge-affected files first when a merge is in progress
|
||||
if (result.mergeState?.isMerging) {
|
||||
const mergeSet = new Set(result.mergeState.mergeAffectedFiles);
|
||||
fileList.sort((a, b) => {
|
||||
const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false);
|
||||
const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false);
|
||||
if (aIsMerge && !bIsMerge) return -1;
|
||||
if (!aIsMerge && bIsMerge) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
if (!cancelled) setFiles(fileList);
|
||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||
if (!cancelled) setMergeState(result.mergeState);
|
||||
// If any files are already staged, pre-select only staged files
|
||||
// Otherwise select all files by default
|
||||
const stagedFiles = fileList.filter((f) => {
|
||||
@@ -579,6 +615,34 @@ export function CommitWorktreeDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
||||
{/* Merge state banner */}
|
||||
{mergeState?.isMerging && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-purple-400">
|
||||
{mergeState.mergeOperationType === 'cherry-pick'
|
||||
? 'Cherry-pick'
|
||||
: mergeState.mergeOperationType === 'rebase'
|
||||
? 'Rebase'
|
||||
: 'Merge'}{' '}
|
||||
in progress
|
||||
</span>
|
||||
{mergeState.conflictFiles.length > 0 ? (
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— {mergeState.conflictFiles.length} file
|
||||
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
|
||||
</span>
|
||||
) : mergeState.isCleanMerge ? (
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— Clean merge, {mergeState.mergeAffectedFiles.length} file
|
||||
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Selection */}
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -625,13 +689,25 @@ export function CommitWorktreeDialog({
|
||||
const isStaged = idx !== ' ' && idx !== '?';
|
||||
const isUnstaged = wt !== ' ' && wt !== '?';
|
||||
const isUntracked = idx === '?' && wt === '?';
|
||||
const isMergeFile =
|
||||
file.isMergeAffected ||
|
||||
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
key={file.path}
|
||||
className={cn(
|
||||
'border-b last:border-b-0',
|
||||
isMergeFile ? 'border-purple-500/30' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
|
||||
isExpanded && 'bg-accent/30'
|
||||
'flex items-center gap-2 px-3 py-1.5 transition-colors group',
|
||||
isMergeFile
|
||||
? 'bg-purple-500/5 hover:bg-purple-500/10'
|
||||
: 'hover:bg-accent/50',
|
||||
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
@@ -651,11 +727,21 @@ export function CommitWorktreeDialog({
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{getFileIcon(file.status)}
|
||||
{isMergeFile ? (
|
||||
<GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
|
||||
) : (
|
||||
getFileIcon(file.status)
|
||||
)}
|
||||
<TruncatedFilePath
|
||||
path={file.path}
|
||||
className="text-xs font-mono flex-1 text-foreground"
|
||||
/>
|
||||
{isMergeFile && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5">
|
||||
<GitMerge className="w-2.5 h-2.5" />
|
||||
{getMergeTypeLabel(file.mergeType)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||
@@ -810,11 +896,16 @@ export function CommitWorktreeDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate w-full block">
|
||||
{remote.url}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
|
||||
{remote.url}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,11 +17,17 @@ import {
|
||||
FileWarning,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
GitMerge,
|
||||
GitCommitHorizontal,
|
||||
FileText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface WorktreeInfo {
|
||||
@@ -37,6 +43,7 @@ type PullPhase =
|
||||
| 'local-changes' // Local changes detected, asking user what to do
|
||||
| 'pulling' // Actively pulling (with or without stash)
|
||||
| 'success' // Pull completed successfully
|
||||
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
|
||||
| 'conflict' // Merge conflicts detected
|
||||
| 'error'; // Something went wrong
|
||||
|
||||
@@ -53,6 +60,9 @@ interface PullResult {
|
||||
stashed?: boolean;
|
||||
stashRestored?: boolean;
|
||||
stashRecoveryFailed?: boolean;
|
||||
isMerge?: boolean;
|
||||
isFastForward?: boolean;
|
||||
mergeAffectedFiles?: string[];
|
||||
}
|
||||
|
||||
interface GitPullDialogProps {
|
||||
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
|
||||
remote?: string;
|
||||
onPulled?: () => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
/** Called when user chooses to commit the merge — opens the commit dialog */
|
||||
onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void;
|
||||
}
|
||||
|
||||
export function GitPullDialog({
|
||||
@@ -71,10 +83,54 @@ export function GitPullDialog({
|
||||
remote,
|
||||
onPulled,
|
||||
onCreateConflictResolutionFeature,
|
||||
onCommitMerge,
|
||||
}: GitPullDialogProps) {
|
||||
const [phase, setPhase] = useState<PullPhase>('checking');
|
||||
const [pullResult, setPullResult] = useState<PullResult | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [rememberChoice, setRememberChoice] = useState(false);
|
||||
const [showMergeFiles, setShowMergeFiles] = useState(false);
|
||||
|
||||
const mergePostAction = useAppStore((s) => s.mergePostAction);
|
||||
const setMergePostAction = useAppStore((s) => s.setMergePostAction);
|
||||
|
||||
/**
|
||||
* Determine the appropriate phase after a successful pull.
|
||||
* If the pull resulted in a merge (not fast-forward) and no conflicts,
|
||||
* check user preference before deciding whether to show merge prompt.
|
||||
*/
|
||||
const handleSuccessfulPull = useCallback(
|
||||
(result: PullResult) => {
|
||||
setPullResult(result);
|
||||
|
||||
if (result.isMerge && !result.hasConflicts) {
|
||||
// Merge happened — check user preference
|
||||
if (mergePostAction === 'commit') {
|
||||
// User preference: auto-commit
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
// Auto-trigger commit dialog
|
||||
if (worktree && onCommitMerge) {
|
||||
onCommitMerge(worktree);
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else if (mergePostAction === 'manual') {
|
||||
// User preference: manual review
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
} else {
|
||||
// No preference — show merge prompt; onPulled will be called from the
|
||||
// user-action handlers (handleCommitMerge / handleMergeManually) once
|
||||
// the user makes their choice, consistent with the conflict phase.
|
||||
setPhase('merge-complete');
|
||||
}
|
||||
} else {
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
}
|
||||
},
|
||||
[mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]
|
||||
);
|
||||
|
||||
const checkForLocalChanges = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
@@ -103,9 +159,7 @@ export function GitPullDialog({
|
||||
setPhase('local-changes');
|
||||
} else if (result.result?.pulled !== undefined) {
|
||||
// No local changes, pull went through (or already up to date)
|
||||
setPullResult(result.result);
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
handleSuccessfulPull(result.result);
|
||||
} else {
|
||||
// Unexpected response: success but no recognizable fields
|
||||
setPullResult(result.result ?? null);
|
||||
@@ -116,18 +170,33 @@ export function GitPullDialog({
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
}, [worktree, remote, handleSuccessfulPull]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
// Keep a ref to the latest checkForLocalChanges to break the circular dependency
|
||||
// between the "reset/start" effect and the callback chain. Without this, any
|
||||
// change in onPulled (passed from the parent) would recreate handleSuccessfulPull
|
||||
// → checkForLocalChanges → re-trigger the effect while the dialog is already open,
|
||||
// causing the pull flow to restart unintentionally.
|
||||
const checkForLocalChangesRef = useRef(checkForLocalChanges);
|
||||
useEffect(() => {
|
||||
checkForLocalChangesRef.current = checkForLocalChanges;
|
||||
});
|
||||
|
||||
// Reset state when dialog opens and start the initial pull check.
|
||||
// Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` —
|
||||
// so that parent callback re-creations don't restart the pull flow mid-flight.
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setPhase('checking');
|
||||
setPullResult(null);
|
||||
setErrorMessage(null);
|
||||
// Start the initial check
|
||||
checkForLocalChanges();
|
||||
setRememberChoice(false);
|
||||
setShowMergeFiles(false);
|
||||
// Start the initial check using the ref so we always call the latest version
|
||||
// without making it a dependency of this effect.
|
||||
checkForLocalChangesRef.current();
|
||||
}
|
||||
}, [open, worktree, checkForLocalChanges]);
|
||||
}, [open, worktree]);
|
||||
|
||||
const handlePullWithStash = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
@@ -155,8 +224,7 @@ export function GitPullDialog({
|
||||
if (result.result?.hasConflicts) {
|
||||
setPhase('conflict');
|
||||
} else if (result.result?.pulled) {
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
handleSuccessfulPull(result.result);
|
||||
} else {
|
||||
// Unrecognized response: no pulled flag and no conflicts
|
||||
console.warn('handlePullWithStash: unrecognized response', result.result);
|
||||
@@ -167,7 +235,7 @@ export function GitPullDialog({
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
}, [worktree, remote, handleSuccessfulPull]);
|
||||
|
||||
const handleResolveWithAI = useCallback(() => {
|
||||
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
||||
@@ -186,6 +254,35 @@ export function GitPullDialog({
|
||||
onOpenChange(false);
|
||||
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
|
||||
|
||||
const handleCommitMerge = useCallback(() => {
|
||||
if (!worktree || !onCommitMerge) {
|
||||
// No handler available — show feedback and bail without persisting preference
|
||||
toast.error('Commit merge is not available', {
|
||||
description: 'The commit merge action is not configured for this context.',
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rememberChoice) {
|
||||
setMergePostAction('commit');
|
||||
}
|
||||
onPulled?.();
|
||||
onCommitMerge(worktree);
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]);
|
||||
|
||||
const handleMergeManually = useCallback(() => {
|
||||
if (rememberChoice) {
|
||||
setMergePostAction('manual');
|
||||
}
|
||||
toast.info('Merge left for manual review', {
|
||||
description: 'Review the merged files and commit when ready.',
|
||||
duration: 5000,
|
||||
});
|
||||
onPulled?.();
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, setMergePostAction, onPulled, onOpenChange]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
@@ -336,6 +433,137 @@ export function GitPullDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Merge Complete Phase — post-merge prompt */}
|
||||
{phase === 'merge-complete' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||
Merge Complete
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
Pull resulted in a merge on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
|
||||
<span>
|
||||
{' '}
|
||||
affecting {pullResult.mergeAffectedFiles.length} file
|
||||
{pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
. How would you like to proceed?
|
||||
</span>
|
||||
|
||||
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowMergeFiles(!showMergeFiles)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{showMergeFiles ? 'Hide' : 'Show'} affected files (
|
||||
{pullResult.mergeAffectedFiles.length})
|
||||
</button>
|
||||
{showMergeFiles && (
|
||||
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
|
||||
{pullResult.mergeAffectedFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
|
||||
<span className="truncate">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pullResult?.stashed &&
|
||||
pullResult?.stashRestored &&
|
||||
!pullResult?.stashRecoveryFailed && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||
Your stashed changes have been restored successfully.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||
Choose how to proceed:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<strong>Commit Merge</strong> — Open the commit dialog with a merge
|
||||
commit message
|
||||
</li>
|
||||
<li>
|
||||
<strong>Review Manually</strong> — Leave the working tree as-is for
|
||||
manual review
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Remember choice option */}
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked === true)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<Settings className="w-3 h-3" />
|
||||
Remember my choice for future merges
|
||||
</label>
|
||||
{(rememberChoice || mergePostAction) && (
|
||||
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-2">
|
||||
<span className="opacity-70">
|
||||
Current:{' '}
|
||||
{mergePostAction === 'commit'
|
||||
? 'auto-commit'
|
||||
: mergePostAction === 'manual'
|
||||
? 'manual review'
|
||||
: 'ask every time'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMergePostAction(null);
|
||||
setRememberChoice(false);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset preference
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
|
||||
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Review Manually
|
||||
</Button>
|
||||
{worktree && onCommitMerge && (
|
||||
<Button
|
||||
onClick={handleCommitMerge}
|
||||
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitCommitHorizontal className="w-4 h-4 mr-2" />
|
||||
Commit Merge
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Conflict Phase */}
|
||||
{phase === 'conflict' && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Post-Merge Prompt Dialog
|
||||
*
|
||||
* Shown after a pull or stash apply results in a clean merge (no conflicts).
|
||||
* Presents the user with two options:
|
||||
* 1. Commit the merge — automatically stage all merge-result files and open commit dialog
|
||||
* 2. Merge manually — leave the working tree as-is for manual review
|
||||
*
|
||||
* The user's choice can be persisted as a preference to avoid repeated prompts.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { GitMerge, GitCommitHorizontal, FileText, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MergePostAction = 'commit' | 'manual' | null;
|
||||
|
||||
interface PostMergePromptDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Branch name where the merge happened */
|
||||
branchName: string;
|
||||
/** Number of files affected by the merge */
|
||||
mergeFileCount: number;
|
||||
/** List of files affected by the merge */
|
||||
mergeAffectedFiles?: string[];
|
||||
/** Called when the user chooses to commit the merge */
|
||||
onCommitMerge: () => void;
|
||||
/** Called when the user chooses to handle the merge manually */
|
||||
onMergeManually: () => void;
|
||||
/** Current saved preference (null = ask every time) */
|
||||
savedPreference?: MergePostAction;
|
||||
/** Called when the user changes the preference */
|
||||
onSavePreference?: (preference: MergePostAction) => void;
|
||||
}
|
||||
|
||||
export function PostMergePromptDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
branchName,
|
||||
mergeFileCount,
|
||||
mergeAffectedFiles,
|
||||
onCommitMerge,
|
||||
onMergeManually,
|
||||
savedPreference,
|
||||
onSavePreference,
|
||||
}: PostMergePromptDialogProps) {
|
||||
const [rememberChoice, setRememberChoice] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
|
||||
// Reset transient state each time the dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRememberChoice(false);
|
||||
setShowFiles(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCommitMerge = useCallback(() => {
|
||||
if (rememberChoice && onSavePreference) {
|
||||
onSavePreference('commit');
|
||||
}
|
||||
onCommitMerge();
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, onSavePreference, onCommitMerge, onOpenChange]);
|
||||
|
||||
const handleMergeManually = useCallback(() => {
|
||||
if (rememberChoice && onSavePreference) {
|
||||
onSavePreference('manual');
|
||||
}
|
||||
onMergeManually();
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, onSavePreference, onMergeManually, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px] w-full max-w-full sm:rounded-xl rounded-none dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||
Merge Complete
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
A merge was successfully completed on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{branchName}</code>
|
||||
{mergeFileCount > 0 && (
|
||||
<span>
|
||||
{' '}
|
||||
affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
. How would you like to proceed?
|
||||
</span>
|
||||
|
||||
{mergeAffectedFiles && mergeAffectedFiles.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowFiles(!showFiles)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{showFiles ? 'Hide' : 'Show'} affected files ({mergeAffectedFiles.length})
|
||||
</button>
|
||||
{showFiles && (
|
||||
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
|
||||
{mergeAffectedFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
|
||||
<span className="truncate">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||
Choose how to proceed:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<strong>Commit Merge</strong> — Stage all merge files and open the commit
|
||||
dialog with a pre-populated merge commit message
|
||||
</li>
|
||||
<li>
|
||||
<strong>Review Manually</strong> — Leave the working tree as-is so you can
|
||||
review changes and commit at your own pace
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Remember choice option */}
|
||||
{onSavePreference && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<Settings className="w-3 h-3" />
|
||||
Remember my choice for future merges
|
||||
</label>
|
||||
{savedPreference && (
|
||||
<button
|
||||
onClick={() => onSavePreference(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
Reset preference
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
|
||||
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Review Manually
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommitMerge}
|
||||
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitCommitHorizontal className="w-4 h-4 mr-2" />
|
||||
Commit Merge
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
@@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
||||
@@ -367,7 +367,7 @@ export function ViewStashesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Archive className="w-5 h-5" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
@@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
|
||||
@@ -94,8 +94,6 @@ export function useBoardActions({
|
||||
skipVerificationInAutoMode,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
@@ -561,38 +559,9 @@ export function useBoardActions({
|
||||
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||
// treat it as null (main worktree) to match how running tasks are stored
|
||||
const rawBranchName = feature.branchName ?? null;
|
||||
const featureBranchName =
|
||||
currentProject?.path &&
|
||||
rawBranchName &&
|
||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||
? null
|
||||
: rawBranchName;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
|
||||
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
|
||||
const featureMaxConcurrency = currentProject
|
||||
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
|
||||
: autoMode.maxConcurrency;
|
||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||
|
||||
if (!canStartInWorktree) {
|
||||
const worktreeDesc = featureBranchName
|
||||
? `worktree "${featureBranchName}"`
|
||||
: 'main worktree';
|
||||
toast.error('Concurrency limit reached', {
|
||||
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
|
||||
featureMaxConcurrency > 1 ? 's' : ''
|
||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Note: No concurrency limit check here. Manual feature starts should never
|
||||
// be blocked by the auto mode concurrency limit. The concurrency limit only
|
||||
// governs how many features the auto-loop picks up automatically.
|
||||
|
||||
// Check for blocking dependencies and show warning if enabled
|
||||
if (enableDependencyBlocking) {
|
||||
@@ -681,18 +650,7 @@ export function useBoardActions({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
autoMode,
|
||||
enableDependencyBlocking,
|
||||
features,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
[enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
|
||||
@@ -163,13 +163,22 @@ export function useBoardDragDrop({
|
||||
|
||||
let targetStatus: ColumnId | null = null;
|
||||
|
||||
// Normalize the over ID: strip 'column-header-' prefix if the card was dropped
|
||||
// directly onto the column header droppable zone (e.g. 'column-header-backlog' → 'backlog')
|
||||
const effectiveOverId = overId.startsWith('column-header-')
|
||||
? overId.replace('column-header-', '')
|
||||
: overId;
|
||||
|
||||
// Check if we dropped on a column
|
||||
const column = COLUMNS.find((c) => c.id === overId);
|
||||
const column = COLUMNS.find((c) => c.id === effectiveOverId);
|
||||
if (column) {
|
||||
targetStatus = column.id;
|
||||
} else if (effectiveOverId.startsWith('pipeline_')) {
|
||||
// Pipeline step column (not in static COLUMNS list)
|
||||
targetStatus = effectiveOverId as ColumnId;
|
||||
} else {
|
||||
// Dropped on another feature - find its column
|
||||
const overFeature = features.find((f) => f.id === overId);
|
||||
const overFeature = features.find((f) => f.id === effectiveOverId);
|
||||
if (overFeature) {
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function DevServerLogsPanel({
|
||||
compact
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
|
||||
@@ -354,12 +354,19 @@ export function WorktreeActionsDropdown({
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:{devServerInfo?.port})
|
||||
{devServerInfo?.urlDetected === false
|
||||
? 'Dev Server Starting...'
|
||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||
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
|
||||
|
||||
@@ -308,7 +308,11 @@ export function WorktreeDropdown({
|
||||
{selectedStatus.devServerRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||
title={
|
||||
selectedStatus.devServerInfo?.urlDetected === false
|
||||
? 'Dev server starting...'
|
||||
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
|
||||
}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
|
||||
@@ -206,6 +206,16 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'dev-server:url-detected': {
|
||||
const { payload } = event;
|
||||
logger.info('Dev server URL detected:', payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
url: payload.url,
|
||||
port: payload.port,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ 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(server.worktreePath, server);
|
||||
serversMap.set(normalizePath(server.worktreePath), {
|
||||
...server,
|
||||
urlDetected: server.urlDetected ?? true,
|
||||
});
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
@@ -38,6 +41,39 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
fetchDevServers();
|
||||
}, [fetchDevServers]);
|
||||
|
||||
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onDevServerLogEvent) return;
|
||||
|
||||
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
|
||||
if (event.type === 'dev-server:url-detected') {
|
||||
const { worktreePath, url, port } = event.payload;
|
||||
const key = normalizePath(worktreePath);
|
||||
let didUpdate = false;
|
||||
setRunningDevServers((prev) => {
|
||||
const existing = prev.get(key);
|
||||
if (!existing) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(key, {
|
||||
...existing,
|
||||
url,
|
||||
port,
|
||||
urlDetected: true,
|
||||
});
|
||||
didUpdate = true;
|
||||
return next;
|
||||
});
|
||||
if (didUpdate) {
|
||||
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
||||
toast.success(`Dev server running on port ${port}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const getWorktreeKey = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const path = worktree.isMain ? projectPath : worktree.path;
|
||||
@@ -68,10 +104,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
worktreePath: result.result!.worktreePath,
|
||||
port: result.result!.port,
|
||||
url: result.result!.url,
|
||||
urlDetected: false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
toast.success(`Dev server started on port ${result.result.port}`);
|
||||
toast.success('Dev server started, detecting port...');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to start dev server');
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
/** Whether the actual URL/port has been detected from server output */
|
||||
urlDetected?: boolean;
|
||||
}
|
||||
|
||||
export interface TestSessionInfo {
|
||||
|
||||
@@ -646,57 +646,101 @@ export function WorktreePanel({
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to pullDialogWorktree so handlePullCompleted can access the current
|
||||
// value without including it in the dependency array. If pullDialogWorktree were
|
||||
// a dep of handlePullCompleted, changing it would recreate the callback, which
|
||||
// would propagate into GitPullDialog's onPulled prop and ultimately re-trigger
|
||||
// the pull-check effect inside the dialog (causing the flow to run twice).
|
||||
const pullDialogWorktreeRef = useRef(pullDialogWorktree);
|
||||
useEffect(() => {
|
||||
pullDialogWorktreeRef.current = pullDialogWorktree;
|
||||
}, [pullDialogWorktree]);
|
||||
|
||||
// Handle pull completed - refresh branches and worktrees
|
||||
const handlePullCompleted = useCallback(() => {
|
||||
// Refresh branch data (ahead/behind counts, tracking) and worktree list
|
||||
// after GitPullDialog completes the pull operation
|
||||
if (pullDialogWorktree) {
|
||||
fetchBranches(pullDialogWorktree.path);
|
||||
if (pullDialogWorktreeRef.current) {
|
||||
fetchBranches(pullDialogWorktreeRef.current.path);
|
||||
}
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]);
|
||||
}, [fetchWorktrees, fetchBranches]);
|
||||
|
||||
// Wrapper for onCommit that works with the pull dialog's simpler WorktreeInfo.
|
||||
// Uses the full pullDialogWorktree when available (via ref to avoid making it
|
||||
// a dep that would cascade into handleSuccessfulPull → checkForLocalChanges recreations).
|
||||
const handleCommitMerge = useCallback(
|
||||
(_simpleWorktree: { path: string; branch: string; isMain: boolean }) => {
|
||||
// Prefer the full worktree object we already have (from ref)
|
||||
if (pullDialogWorktreeRef.current) {
|
||||
onCommit(pullDialogWorktreeRef.current);
|
||||
}
|
||||
},
|
||||
[onCommit]
|
||||
);
|
||||
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
// Now opens the pull dialog which handles stash management and conflict resolution
|
||||
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog first
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - open pull dialog directly with that remote
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
setPullDialogRemote(remoteName);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
} else {
|
||||
// No remotes - open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
// If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
|
||||
const handlePullWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
// If the branch already tracks a remote, pull from it directly — no dialog needed
|
||||
const tracked = getTrackingRemote(worktree.path);
|
||||
if (tracked) {
|
||||
setPullDialogRemote(tracked);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog
|
||||
// Multiple remotes and no tracking remote - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - open pull dialog directly with that remote
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
setPullDialogRemote(remoteName);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
} else {
|
||||
// No remotes - open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[getTrackingRemote]
|
||||
);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
// If the branch has a tracked remote, push to it directly (skip the remote selection dialog)
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
// If the branch already tracks a remote, push to it directly — no dialog needed
|
||||
const tracked = getTrackingRemote(worktree.path);
|
||||
if (tracked) {
|
||||
handlePush(worktree, tracked);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes and no tracking remote - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('push');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
@@ -713,7 +757,7 @@ export function WorktreePanel({
|
||||
handlePush(worktree);
|
||||
}
|
||||
},
|
||||
[handlePush]
|
||||
[handlePush, getTrackingRemote]
|
||||
);
|
||||
|
||||
// Handle confirming remote selection for pull/push
|
||||
@@ -992,6 +1036,7 @@ export function WorktreePanel({
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
onCommitMerge={handleCommitMerge}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
@@ -1445,6 +1490,7 @@ export function WorktreePanel({
|
||||
onOpenChange={setViewStashesDialogOpen}
|
||||
worktree={viewStashesWorktree}
|
||||
onStashApplied={handleStashApplied}
|
||||
onStashApplyConflict={onStashApplyConflict}
|
||||
/>
|
||||
|
||||
{/* Cherry Pick Dialog */}
|
||||
@@ -1463,6 +1509,7 @@ export function WorktreePanel({
|
||||
worktree={pullDialogWorktree}
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCommitMerge={handleCommitMerge}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,9 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
|
||||
|
||||
// Stable empty array reference to prevent unnecessary re-renders when no copy files are set
|
||||
const EMPTY_FILES: string[] = [];
|
||||
|
||||
interface WorktreePreferencesSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
@@ -38,20 +41,30 @@ interface InitScriptResponse {
|
||||
}
|
||||
|
||||
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
|
||||
// Use direct store subscriptions (not getter functions) so the component
|
||||
// properly re-renders when these values change in the store.
|
||||
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
|
||||
const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees);
|
||||
const projectUseWorktrees = useAppStore((s) => s.useWorktreesByProject[project.path]);
|
||||
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
|
||||
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
||||
const showIndicator = useAppStore(
|
||||
(s) => s.showInitScriptIndicatorByProject[project.path] ?? true
|
||||
);
|
||||
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
||||
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
||||
const defaultDeleteBranch = useAppStore(
|
||||
(s) => s.defaultDeleteBranchByProject[project.path] ?? false
|
||||
);
|
||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||
const autoDismiss = useAppStore(
|
||||
(s) => s.autoDismissInitScriptIndicatorByProject[project.path] ?? true
|
||||
);
|
||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||
const copyFiles = useAppStore((s) => s.worktreeCopyFilesByProject[project.path] ?? []);
|
||||
// Use a stable empty array reference to prevent new array on every render when
|
||||
// worktreeCopyFilesByProject[project.path] is undefined (not yet loaded).
|
||||
const copyFilesFromStore = useAppStore((s) => s.worktreeCopyFilesByProject[project.path]);
|
||||
const copyFiles = copyFilesFromStore ?? EMPTY_FILES;
|
||||
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
|
||||
|
||||
// Get effective worktrees setting (project override or global fallback)
|
||||
const projectUseWorktrees = getProjectUseWorktrees(project.path);
|
||||
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
|
||||
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
@@ -65,11 +78,6 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
const [newCopyFilePath, setNewCopyFilePath] = useState('');
|
||||
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
|
||||
|
||||
// Get the current settings for this project
|
||||
const showIndicator = getShowInitScriptIndicator(project.path);
|
||||
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
|
||||
const autoDismiss = getAutoDismissInitScriptIndicator(project.path);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = scriptContent !== originalContent;
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Workflow, RotateCcw, Replace, Sparkles } from 'lucide-react';
|
||||
import { Workflow, RotateCcw, Replace, Sparkles, Brain } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PhaseModelSelector } from './phase-model-selector';
|
||||
import { BulkReplaceDialog } from './bulk-replace-dialog';
|
||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
import type { PhaseModelKey, PhaseModelEntry, ThinkingLevel } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
REASONING_EFFORT_LEVELS,
|
||||
} from '@automaker/types';
|
||||
|
||||
interface PhaseConfig {
|
||||
key: PhaseModelKey;
|
||||
@@ -161,6 +165,121 @@ function FeatureDefaultModelSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// Thinking level options with descriptions for the settings UI
|
||||
const THINKING_LEVEL_OPTIONS: { id: ThinkingLevel; label: string; description: string }[] = [
|
||||
{ id: 'none', label: 'None', description: 'No extended thinking' },
|
||||
{ id: 'low', label: 'Low', description: 'Light reasoning (1k tokens)' },
|
||||
{ id: 'medium', label: 'Medium', description: 'Moderate reasoning (10k tokens)' },
|
||||
{ id: 'high', label: 'High', description: 'Deep reasoning (16k tokens)' },
|
||||
{ id: 'ultrathink', label: 'Ultra', description: 'Maximum reasoning (32k tokens)' },
|
||||
{ id: 'adaptive', label: 'Adaptive', description: 'Model decides reasoning depth' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default thinking level / reasoning effort section.
|
||||
* These defaults are applied when selecting a model via the primary button
|
||||
* in the two-stage model selector (i.e. clicking the model name directly).
|
||||
*/
|
||||
function DefaultThinkingLevelSection() {
|
||||
const {
|
||||
defaultThinkingLevel,
|
||||
setDefaultThinkingLevel,
|
||||
defaultReasoningEffort,
|
||||
setDefaultReasoningEffort,
|
||||
} = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Quick-Select Defaults</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Thinking/reasoning level applied when quick-selecting a model from the dropdown. You can
|
||||
always fine-tune per model via the expand arrow.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* Default Thinking Level (Claude models) */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 pr-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center">
|
||||
<Brain className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">Default Thinking Level</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applied to Claude models when quick-selected
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
{THINKING_LEVEL_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setDefaultThinkingLevel(option.id)}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
|
||||
'border',
|
||||
defaultThinkingLevel === option.id
|
||||
? 'bg-primary text-primary-foreground border-primary shadow-sm'
|
||||
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title={option.description}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Reasoning Effort (Codex models) */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 pr-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Brain className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">Default Reasoning Effort</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applied to Codex/OpenAI models when quick-selected
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
{REASONING_EFFORT_LEVELS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setDefaultReasoningEffort(option.id)}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
|
||||
'border',
|
||||
defaultReasoningEffort === option.id
|
||||
? 'bg-primary text-primary-foreground border-primary shadow-sm'
|
||||
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title={option.description}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelDefaultsSection() {
|
||||
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
@@ -222,6 +341,9 @@ export function ModelDefaultsSection() {
|
||||
{/* Feature Defaults */}
|
||||
<FeatureDefaultModelSection />
|
||||
|
||||
{/* Default Thinking Level / Reasoning Effort */}
|
||||
<DefaultThinkingLevelSection />
|
||||
|
||||
{/* Quick Tasks */}
|
||||
<PhaseGroup
|
||||
title="Quick Tasks"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ import {
|
||||
X,
|
||||
SquarePlus,
|
||||
Settings,
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
FolderGit,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
@@ -28,6 +31,17 @@ import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -255,6 +269,8 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
setTerminalScrollbackLines,
|
||||
setTerminalScreenReaderMode,
|
||||
updateTerminalPanelSizes,
|
||||
currentWorktreeByProject,
|
||||
worktreesByProject,
|
||||
} = useAppStore();
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -946,13 +962,50 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
}
|
||||
};
|
||||
|
||||
// Helper: find the branchName of the given session ID within a layout tree
|
||||
const findSessionBranchName = (
|
||||
layout: TerminalPanelContent | null,
|
||||
sessionId: string
|
||||
): string | undefined => {
|
||||
if (!layout) return undefined;
|
||||
if (layout.type === 'terminal') {
|
||||
return layout.sessionId === sessionId ? layout.branchName : undefined;
|
||||
}
|
||||
if (layout.type === 'split') {
|
||||
for (const panel of layout.panels) {
|
||||
const found = findSessionBranchName(panel, sessionId);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Helper: resolve the worktree cwd and branchName for the currently active terminal session.
|
||||
// Returns { cwd, branchName } if the active terminal was opened in a worktree, or {} otherwise.
|
||||
const getActiveSessionWorktreeInfo = (): { cwd?: string; branchName?: string } => {
|
||||
const activeSessionId = terminalState.activeSessionId;
|
||||
if (!activeSessionId || !activeTab?.layout || !currentProject) return {};
|
||||
|
||||
const branchName = findSessionBranchName(activeTab.layout, activeSessionId);
|
||||
if (!branchName) return {};
|
||||
|
||||
// Look up the worktree path for this branch in the project's worktree list
|
||||
const projectWorktrees = worktreesByProject[currentProject.path] ?? [];
|
||||
const worktree = projectWorktrees.find((wt) => wt.branch === branchName);
|
||||
if (!worktree) return { branchName };
|
||||
|
||||
return { cwd: worktree.path, branchName };
|
||||
};
|
||||
|
||||
// Create a new terminal session
|
||||
// targetSessionId: the terminal to split (if splitting an existing terminal)
|
||||
// customCwd: optional working directory to use instead of the current project path
|
||||
// branchName: optional branch name to display in the terminal panel header
|
||||
const createTerminal = async (
|
||||
direction?: 'horizontal' | 'vertical',
|
||||
targetSessionId?: string,
|
||||
customCwd?: string
|
||||
customCwd?: string,
|
||||
branchName?: string
|
||||
) => {
|
||||
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
|
||||
return;
|
||||
@@ -971,7 +1024,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
addTerminalToLayout(data.data.id, direction, targetSessionId);
|
||||
addTerminalToLayout(data.data.id, direction, targetSessionId, branchName);
|
||||
// Mark this session as new for running initial command
|
||||
if (defaultRunScript) {
|
||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||
@@ -1004,11 +1057,18 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
};
|
||||
|
||||
// Create terminal in new tab
|
||||
const createTerminalInNewTab = async () => {
|
||||
// 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) => {
|
||||
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided cwd/branch, or inherit from active session's worktree
|
||||
const { cwd: worktreeCwd, branchName: worktreeBranch } = customCwd
|
||||
? { cwd: customCwd, branchName: branchName }
|
||||
: getActiveSessionWorktreeInfo();
|
||||
|
||||
const tabId = addTerminalTab();
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -1018,14 +1078,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
body: { cwd: worktreeCwd || currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Add to the newly created tab
|
||||
// Add to the newly created tab (passing branchName so the panel header shows the branch badge)
|
||||
const { addTerminalToTab } = useAppStore.getState();
|
||||
addTerminalToTab(data.data.id, tabId);
|
||||
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
|
||||
// Mark this session as new for running initial command
|
||||
if (defaultRunScript) {
|
||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||
@@ -1344,8 +1404,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
isActive={terminalState.activeSessionId === content.sessionId}
|
||||
onFocus={() => setActiveTerminalSession(content.sessionId)}
|
||||
onClose={() => killTerminal(content.sessionId)}
|
||||
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)}
|
||||
onSplitVertical={() => createTerminal('vertical', content.sessionId)}
|
||||
onSplitHorizontal={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('horizontal', content.sessionId, cwd, branchName);
|
||||
}}
|
||||
onSplitVertical={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('vertical', content.sessionId, cwd, branchName);
|
||||
}}
|
||||
onNewTab={createTerminalInNewTab}
|
||||
onNavigateUp={() => navigateToTerminal('up')}
|
||||
onNavigateDown={() => navigateToTerminal('down')}
|
||||
@@ -1502,6 +1568,15 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
|
||||
// No terminals yet - show welcome screen
|
||||
if (terminalState.tabs.length === 0) {
|
||||
// Get the current worktree for this project (if any)
|
||||
const currentWorktreeInfo = currentProject
|
||||
? (currentWorktreeByProject[currentProject.path] ?? null)
|
||||
: null;
|
||||
// Only show worktree button when the current worktree has a specific path set
|
||||
// (non-null path means a worktree is selected, as opposed to the main project)
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-brand-500/10 mb-4">
|
||||
@@ -1518,10 +1593,40 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button onClick={() => createTerminal()}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Terminal
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-xs">
|
||||
{currentWorktreePath && (
|
||||
<Button
|
||||
className="w-full flex-col h-auto py-2"
|
||||
onClick={() =>
|
||||
createTerminal(
|
||||
undefined,
|
||||
undefined,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch ?? undefined
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 mr-2 shrink-0" />
|
||||
Open Terminal in Worktree
|
||||
</span>
|
||||
{currentWorktreeBranch && (
|
||||
<span className="text-xs opacity-70 truncate max-w-full px-2">
|
||||
{currentWorktreeBranch}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={currentWorktreePath ? 'outline' : 'default'}
|
||||
onClick={() => createTerminal()}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Terminal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status?.platform && (
|
||||
<p className="text-xs text-muted-foreground mt-6">
|
||||
@@ -1564,14 +1669,94 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
|
||||
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
|
||||
|
||||
{/* New tab button */}
|
||||
<button
|
||||
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
onClick={createTerminalInNewTab}
|
||||
title="New Tab"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{/* New tab split button */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex items-center justify-center p-1.5 rounded-l hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
onClick={() => createTerminalInNewTab()}
|
||||
title="New Tab"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center justify-center px-0.5 py-1.5 rounded-r hover:bg-accent text-muted-foreground hover:text-foreground border-l border-border"
|
||||
title="New Terminal Options"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="bottom" className="w-56">
|
||||
<DropdownMenuItem onClick={() => createTerminalInNewTab()} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('horizontal', undefined, cwd, branchName);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
Split Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('vertical', undefined, cwd, branchName);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<SplitSquareVertical className="h-4 w-4" />
|
||||
Split Down
|
||||
</DropdownMenuItem>
|
||||
{/* Worktree options - show when project has worktrees */}
|
||||
{(() => {
|
||||
const projectWorktrees = currentProject
|
||||
? (worktreesByProject[currentProject.path] ?? [])
|
||||
: [];
|
||||
if (projectWorktrees.length === 0) return null;
|
||||
const mainWorktree = projectWorktrees.find((wt) => wt.isMain);
|
||||
const featureWorktrees = projectWorktrees.filter((wt) => !wt.isMain);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Open in Worktree
|
||||
</DropdownMenuLabel>
|
||||
{mainWorktree && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
createTerminalInNewTab(mainWorktree.path, mainWorktree.branch)
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderGit className="h-4 w-4" />
|
||||
<span className="truncate">{mainWorktree.branch}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
|
||||
main
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{featureWorktrees.map((wt) => (
|
||||
<DropdownMenuItem
|
||||
key={wt.path}
|
||||
onClick={() => createTerminalInNewTab(wt.path, wt.branch)}
|
||||
className="gap-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span className="truncate">{wt.branch}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar buttons */}
|
||||
@@ -1580,7 +1765,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => createTerminal('horizontal')}
|
||||
onClick={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('horizontal', undefined, cwd, branchName);
|
||||
}}
|
||||
title="Split Right"
|
||||
>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
@@ -1589,7 +1777,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => createTerminal('vertical')}
|
||||
onClick={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('vertical', undefined, cwd, branchName);
|
||||
}}
|
||||
title="Split Down"
|
||||
>
|
||||
<SplitSquareVertical className="h-4 w-4" />
|
||||
@@ -1771,12 +1962,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
||||
isActive={true}
|
||||
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
|
||||
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
|
||||
onSplitHorizontal={() =>
|
||||
createTerminal('horizontal', terminalState.maximizedSessionId!)
|
||||
}
|
||||
onSplitVertical={() =>
|
||||
createTerminal('vertical', terminalState.maximizedSessionId!)
|
||||
}
|
||||
onSplitHorizontal={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('horizontal', terminalState.maximizedSessionId!, cwd, branchName);
|
||||
}}
|
||||
onSplitVertical={() => {
|
||||
const { cwd, branchName } = getActiveSessionWorktreeInfo();
|
||||
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
|
||||
}}
|
||||
onNewTab={createTerminalInNewTab}
|
||||
onSessionInvalid={() => {
|
||||
const sessionId = terminalState.maximizedSessionId!;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
ClipboardPaste,
|
||||
CheckSquare,
|
||||
TextSelect,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StickyModifierKeys, type StickyModifier } from './sticky-modifier-keys';
|
||||
|
||||
/**
|
||||
* ANSI escape sequences for special keys.
|
||||
@@ -37,6 +49,20 @@ interface MobileTerminalShortcutsProps {
|
||||
onSendInput: (data: string) => void;
|
||||
/** Whether the terminal is connected and ready */
|
||||
isConnected: boolean;
|
||||
/** Currently active sticky modifier (Ctrl or Alt) */
|
||||
activeModifier: StickyModifier;
|
||||
/** Callback when sticky modifier is toggled */
|
||||
onModifierChange: (modifier: StickyModifier) => void;
|
||||
/** Callback to copy selected text to clipboard */
|
||||
onCopy?: () => void;
|
||||
/** Callback to paste from clipboard into terminal */
|
||||
onPaste?: () => void;
|
||||
/** Callback to select all terminal content */
|
||||
onSelectAll?: () => void;
|
||||
/** Callback to toggle text selection mode (renders selectable text overlay) */
|
||||
onToggleSelectMode?: () => void;
|
||||
/** Whether text selection mode is currently active */
|
||||
isSelectMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +76,13 @@ interface MobileTerminalShortcutsProps {
|
||||
export function MobileTerminalShortcuts({
|
||||
onSendInput,
|
||||
isConnected,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
onToggleSelectMode,
|
||||
isSelectMode,
|
||||
}: MobileTerminalShortcutsProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -135,6 +168,54 @@ export function MobileTerminalShortcuts({
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Sticky modifier keys (Ctrl, Alt) - at the beginning of the bar */}
|
||||
<StickyModifierKeys
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={onModifierChange}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Clipboard actions */}
|
||||
{onToggleSelectMode && (
|
||||
<IconShortcutButton
|
||||
icon={TextSelect}
|
||||
title={isSelectMode ? 'Exit select mode' : 'Select text'}
|
||||
onPress={onToggleSelectMode}
|
||||
disabled={!isConnected}
|
||||
active={isSelectMode}
|
||||
/>
|
||||
)}
|
||||
{onSelectAll && (
|
||||
<IconShortcutButton
|
||||
icon={CheckSquare}
|
||||
title="Select all"
|
||||
onPress={onSelectAll}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
{onCopy && (
|
||||
<IconShortcutButton
|
||||
icon={Copy}
|
||||
title="Copy selection"
|
||||
onPress={onCopy}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
{onPaste && (
|
||||
<IconShortcutButton
|
||||
icon={ClipboardPaste}
|
||||
title="Paste from clipboard"
|
||||
onPress={onPaste}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Special keys */}
|
||||
<ShortcutButton
|
||||
label="Esc"
|
||||
@@ -300,3 +381,42 @@ function ArrowButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-based shortcut button for clipboard actions.
|
||||
* Uses a Lucide icon instead of text label for a cleaner mobile UI.
|
||||
*/
|
||||
function IconShortcutButton({
|
||||
icon: Icon,
|
||||
title,
|
||||
onPress,
|
||||
disabled = false,
|
||||
active = false,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'p-2 rounded-md shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
|
||||
'active:scale-95 touch-manipulation',
|
||||
active
|
||||
? 'bg-brand-500/20 text-brand-500 ring-1 ring-brand-500/40'
|
||||
: 'bg-muted/80 text-foreground hover:bg-accent',
|
||||
disabled && 'opacity-40 pointer-events-none'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // Prevent focus stealing from terminal
|
||||
onPress();
|
||||
}}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,14 +51,11 @@ import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { writeToClipboard, readFromClipboard } from '@/lib/clipboard-utils';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
|
||||
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
|
||||
import {
|
||||
StickyModifierKeys,
|
||||
applyStickyModifier,
|
||||
type StickyModifier,
|
||||
} from './sticky-modifier-keys';
|
||||
import { applyStickyModifier, type StickyModifier } from './sticky-modifier-keys';
|
||||
import { TerminalScriptsDropdown } from './terminal-scripts-dropdown';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
@@ -81,6 +78,9 @@ const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pas
|
||||
const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes
|
||||
const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket
|
||||
|
||||
// Mobile overlay buffer cap - limit lines read from terminal buffer to avoid DOM blow-up on mobile
|
||||
const MAX_OVERLAY_LINES = 1000; // Maximum number of lines to read for the mobile select-mode overlay
|
||||
|
||||
interface TerminalPanelProps {
|
||||
sessionId: string;
|
||||
authToken: string | null;
|
||||
@@ -157,6 +157,9 @@ export function TerminalPanel({
|
||||
const [isImageDragOver, setIsImageDragOver] = useState(false);
|
||||
const [isProcessingImage, setIsProcessingImage] = useState(false);
|
||||
const hasRunInitialCommandRef = useRef(false);
|
||||
// Long-press timer for mobile context menu
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const longPressTouchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
// Tracks whether the connected shell is a Windows shell (PowerShell, cmd, etc.).
|
||||
// Maintained as a ref (not state) so sendCommand can read the current value without
|
||||
// causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage
|
||||
@@ -169,6 +172,10 @@ export function TerminalPanel({
|
||||
const showSearchRef = useRef(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
// Mobile text selection mode - renders terminal buffer as selectable DOM text
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [selectModeText, setSelectModeText] = useState('');
|
||||
|
||||
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
|
||||
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
|
||||
const stickyModifierRef = useRef<StickyModifier>(null);
|
||||
@@ -330,9 +337,16 @@ export function TerminalPanel({
|
||||
try {
|
||||
// Strip any ANSI escape codes that might be in the selection
|
||||
const cleanText = stripAnsi(selection);
|
||||
await navigator.clipboard.writeText(cleanText);
|
||||
toast.success('Copied to clipboard');
|
||||
return true;
|
||||
const success = await writeToClipboard(cleanText);
|
||||
if (success) {
|
||||
toast.success('Copied to clipboard');
|
||||
return true;
|
||||
} else {
|
||||
toast.error('Copy failed', {
|
||||
description: 'Could not access clipboard',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Copy failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -399,7 +413,7 @@ export function TerminalPanel({
|
||||
if (!terminal || !wsRef.current) return;
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const text = await readFromClipboard();
|
||||
if (!text) {
|
||||
toast.error('Nothing to paste', {
|
||||
description: 'Clipboard is empty',
|
||||
@@ -428,7 +442,9 @@ export function TerminalPanel({
|
||||
toast.error('Paste failed', {
|
||||
description: errorMessage.includes('permission')
|
||||
? 'Clipboard permission denied'
|
||||
: 'Could not read from clipboard',
|
||||
: errorMessage.includes('not supported')
|
||||
? errorMessage
|
||||
: 'Could not read from clipboard',
|
||||
});
|
||||
}
|
||||
}, [sendTextInChunks]);
|
||||
@@ -439,6 +455,45 @@ export function TerminalPanel({
|
||||
xtermRef.current?.selectAll();
|
||||
}, []);
|
||||
|
||||
// Extract terminal buffer text for mobile selection mode overlay
|
||||
const getTerminalBufferText = useCallback((): string => {
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) return '';
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Cap the number of lines read to MAX_OVERLAY_LINES to avoid blowing up the DOM on mobile
|
||||
const startIndex = Math.max(0, buffer.length - MAX_OVERLAY_LINES);
|
||||
for (let i = startIndex; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
}
|
||||
}
|
||||
|
||||
// Trim trailing empty lines but keep internal structure
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}, []);
|
||||
|
||||
// Toggle mobile text selection mode
|
||||
const toggleSelectMode = useCallback(() => {
|
||||
if (isSelectMode) {
|
||||
setIsSelectMode(false);
|
||||
setSelectModeText('');
|
||||
} else {
|
||||
const text = getTerminalBufferText();
|
||||
// Strip ANSI escape codes for clean display
|
||||
const cleanText = stripAnsi(text);
|
||||
setSelectModeText(cleanText);
|
||||
setIsSelectMode(true);
|
||||
}
|
||||
}, [isSelectMode, getTerminalBufferText]);
|
||||
|
||||
// Clear terminal
|
||||
const clearTerminal = useCallback(() => {
|
||||
xtermRef.current?.clear();
|
||||
@@ -944,17 +999,17 @@ export function TerminalPanel({
|
||||
const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey;
|
||||
|
||||
// Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention)
|
||||
// Don't preventDefault() — allow the native browser copy to work alongside our custom copy
|
||||
if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') {
|
||||
event.preventDefault();
|
||||
copySelectionRef.current();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT
|
||||
// Don't preventDefault() when copying — allow the native browser copy to work alongside our custom copy
|
||||
if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') {
|
||||
const hasSelection = terminal.hasSelection();
|
||||
if (hasSelection) {
|
||||
event.preventDefault();
|
||||
copySelectionRef.current();
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
@@ -964,9 +1019,11 @@ export function TerminalPanel({
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste
|
||||
// Don't preventDefault() — allow the native browser paste to work.
|
||||
// Return false to prevent xterm from sending \x16 (literal next),
|
||||
// but the browser's native paste event will still fire and xterm will
|
||||
// receive the pasted text through its onData handler.
|
||||
if (modKey && !otherModKey && !event.altKey && code === 'KeyV') {
|
||||
event.preventDefault();
|
||||
pasteFromClipboardRef.current();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1014,6 +1071,12 @@ export function TerminalPanel({
|
||||
resizeDebounceRef.current = null;
|
||||
}
|
||||
|
||||
// Clear long-press timer
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear search decorations before disposing to prevent visual artifacts
|
||||
if (searchAddonRef.current) {
|
||||
searchAddonRef.current.clearDecorations();
|
||||
@@ -1571,6 +1634,17 @@ export function TerminalPanel({
|
||||
buttons[focusedMenuIndex]?.focus();
|
||||
}, [focusedMenuIndex, contextMenu]);
|
||||
|
||||
// Reset select mode when viewport transitions from mobile to non-mobile.
|
||||
// The select-mode overlay is only rendered when (isSelectMode && isMobile), so if the
|
||||
// viewport becomes non-mobile while isSelectMode is true the overlay disappears but the
|
||||
// state is left dirty with no UI to clear it. Resetting here keeps state consistent.
|
||||
useEffect(() => {
|
||||
if (!isMobile && isSelectMode) {
|
||||
setIsSelectMode(false);
|
||||
setSelectModeText('');
|
||||
}
|
||||
}, [isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle right-click context menu with boundary checking
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1602,6 +1676,77 @@ export function TerminalPanel({
|
||||
setContextMenu({ x, y });
|
||||
}, []);
|
||||
|
||||
// Long-press handlers for mobile context menu
|
||||
// On mobile, there's no right-click, so we trigger the context menu on long-press (500ms hold)
|
||||
const LONG_PRESS_DURATION = 500; // ms
|
||||
const LONG_PRESS_MOVE_THRESHOLD = 10; // px - cancel if finger moves more than this
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!isMobile) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Clear any existing timer before creating a new one to avoid orphaned timeouts
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Capture initial touch coordinates into an immutable local snapshot
|
||||
const startPos = { x: touch.clientX, y: touch.clientY };
|
||||
longPressTouchStartRef.current = startPos;
|
||||
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
// Use the locally captured startPos rather than re-reading the ref
|
||||
// Menu dimensions (approximate)
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 152;
|
||||
const padding = 8;
|
||||
|
||||
let x = startPos.x;
|
||||
let y = startPos.y;
|
||||
|
||||
// Boundary checks
|
||||
if (x + menuWidth + padding > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth - padding;
|
||||
}
|
||||
if (y + menuHeight + padding > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight - padding;
|
||||
}
|
||||
x = Math.max(padding, x);
|
||||
y = Math.max(padding, y);
|
||||
|
||||
setContextMenu({ x, y });
|
||||
longPressTouchStartRef.current = null;
|
||||
}, LONG_PRESS_DURATION);
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!longPressTimerRef.current || !longPressTouchStartRef.current) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - longPressTouchStartRef.current.x;
|
||||
const dy = touch.clientY - longPressTouchStartRef.current.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > LONG_PRESS_MOVE_THRESHOLD) {
|
||||
// Finger moved too far, cancel long-press
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
longPressTouchStartRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
longPressTouchStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Convert file to base64
|
||||
const fileToBase64 = useCallback((file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -2092,15 +2237,6 @@ export function TerminalPanel({
|
||||
|
||||
<div className="w-px h-3 mx-0.5 bg-border" />
|
||||
|
||||
{/* Sticky modifier keys (Ctrl, Alt) */}
|
||||
<StickyModifierKeys
|
||||
activeModifier={stickyModifier}
|
||||
onModifierChange={handleStickyModifierChange}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
/>
|
||||
|
||||
<div className="w-px h-3 mx-0.5 bg-border" />
|
||||
|
||||
{/* Split/close buttons */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2221,24 +2357,116 @@ export function TerminalPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */}
|
||||
{/* Mobile shortcuts bar - special keys, clipboard, and arrow keys for touch devices */}
|
||||
{isMobile && (
|
||||
<MobileTerminalShortcuts
|
||||
onSendInput={sendTerminalInput}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
activeModifier={stickyModifier}
|
||||
onModifierChange={handleStickyModifierChange}
|
||||
onSelectAll={selectAll}
|
||||
onCopy={() => {
|
||||
// On mobile, if nothing is selected, auto-select all before copying.
|
||||
// This provides a convenient "tap to copy all" experience since
|
||||
// touch-based text selection in xterm.js canvas is not possible.
|
||||
const terminal = xtermRef.current;
|
||||
if (terminal && !terminal.hasSelection()) {
|
||||
terminal.selectAll();
|
||||
}
|
||||
copySelectionRef.current();
|
||||
}}
|
||||
onPaste={() => pasteFromClipboardRef.current()}
|
||||
onToggleSelectMode={toggleSelectMode}
|
||||
isSelectMode={isSelectMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal container - uses terminal theme */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden relative"
|
||||
style={{ backgroundColor: currentTerminalTheme.background }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragOver={handleImageDragOver}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDrop={handleImageDrop}
|
||||
/>
|
||||
{/* Terminal area wrapper - relative container for the terminal and selection overlay */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{/* Terminal container - xterm.js mounts here */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: currentTerminalTheme.background }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
onDragOver={handleImageDragOver}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDrop={handleImageDrop}
|
||||
/>
|
||||
|
||||
{/* Mobile text selection overlay - renders terminal buffer as native selectable text.
|
||||
Overlays the canvas so users can use native touch selection on real DOM text.
|
||||
xterm.js renders to a <canvas>, which prevents native text selection on mobile.
|
||||
This overlay shows the same content as real DOM text that supports touch selection. */}
|
||||
{isSelectMode && isMobile && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col">
|
||||
{/* Header bar with copy/done actions */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-brand-500/95 backdrop-blur-sm text-white shrink-0">
|
||||
<span className="text-xs font-medium">Touch & hold to select text</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white/20 hover:bg-white/30 active:scale-95 transition-all touch-manipulation"
|
||||
onClick={async () => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString();
|
||||
if (selectedText) {
|
||||
const success = await writeToClipboard(selectedText);
|
||||
if (success) {
|
||||
toast.success('Copied to clipboard');
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
} else {
|
||||
const success = await writeToClipboard(selectModeText);
|
||||
if (success) {
|
||||
toast.success('Copied all text to clipboard');
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white/20 hover:bg-white/30 active:scale-95 transition-all touch-manipulation"
|
||||
onClick={() => {
|
||||
setIsSelectMode(false);
|
||||
setSelectModeText('');
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Scrollable text content matching terminal appearance */}
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={
|
||||
{
|
||||
backgroundColor: currentTerminalTheme.background,
|
||||
color: currentTerminalTheme.foreground,
|
||||
fontFamily: getTerminalFontFamily(fontFamily),
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${lineHeight || 1.0}`,
|
||||
padding: '12px 16px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
touchAction: 'auto',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{selectModeText || 'No terminal content to select.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jump to bottom button - shown when scrolled up */}
|
||||
{!isAtBottom && (
|
||||
|
||||
@@ -32,6 +32,7 @@ export function useGitDiffs(projectPath: string | undefined, enabled = true) {
|
||||
return {
|
||||
files: result.files ?? [],
|
||||
diff: result.diff ?? '',
|
||||
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||
};
|
||||
},
|
||||
enabled: !!projectPath && enabled,
|
||||
|
||||
@@ -160,6 +160,7 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str
|
||||
return {
|
||||
files: result.files ?? [],
|
||||
diff: result.diff ?? '',
|
||||
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||
};
|
||||
},
|
||||
enabled: !!projectPath && !!featureId,
|
||||
|
||||
@@ -157,8 +157,40 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
// Ref to prevent refreshStatus from overwriting optimistic state during start/stop
|
||||
// Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state
|
||||
// during start/stop transitions.
|
||||
const isTransitioningRef = useRef(false);
|
||||
// Tracks specifically a restart-for-concurrency transition. When true, the
|
||||
// auto_mode_started WebSocket handler will clear isTransitioningRef, ensuring
|
||||
// delayed auto_mode_stopped events that arrive after the HTTP calls complete
|
||||
// (but before the WebSocket events) are still suppressed.
|
||||
const isRestartTransitionRef = useRef(false);
|
||||
// Safety timeout ID to clear the transition flag if the auto_mode_started event never arrives
|
||||
const restartSafetyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Use refs for mutable state in refreshStatus to avoid unstable callback identity.
|
||||
// This prevents the useEffect that calls refreshStatus on mount from re-firing
|
||||
// every time isAutoModeRunning or runningAutoTasks changes, which was a source of
|
||||
// flickering as refreshStatus would race with WebSocket events and optimistic updates.
|
||||
const isAutoModeRunningRef = useRef(isAutoModeRunning);
|
||||
const runningAutoTasksRef = useRef(runningAutoTasks);
|
||||
useEffect(() => {
|
||||
isAutoModeRunningRef.current = isAutoModeRunning;
|
||||
}, [isAutoModeRunning]);
|
||||
useEffect(() => {
|
||||
runningAutoTasksRef.current = runningAutoTasks;
|
||||
}, [runningAutoTasks]);
|
||||
|
||||
// Clean up safety timeout on unmount to prevent timer leaks and misleading log warnings
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (restartSafetyTimeoutRef.current) {
|
||||
clearTimeout(restartSafetyTimeoutRef.current);
|
||||
restartSafetyTimeoutRef.current = null;
|
||||
}
|
||||
isRestartTransitionRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
@@ -175,20 +207,25 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
const backendRunningFeatures = result.runningFeatures ?? [];
|
||||
// Read latest state from refs to avoid stale closure values
|
||||
const currentIsRunning = isAutoModeRunningRef.current;
|
||||
const currentRunningTasks = runningAutoTasksRef.current;
|
||||
const needsSync =
|
||||
backendIsRunning !== isAutoModeRunning ||
|
||||
backendIsRunning !== currentIsRunning ||
|
||||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
|
||||
(backendIsRunning &&
|
||||
Array.isArray(backendRunningFeatures) &&
|
||||
backendRunningFeatures.length > 0 &&
|
||||
!arraysEqual(backendRunningFeatures, runningAutoTasks)) ||
|
||||
!arraysEqual(backendRunningFeatures, currentRunningTasks)) ||
|
||||
// Also sync when UI has stale running tasks but backend has none
|
||||
// (handles server restart where features were reconciled to backlog/ready)
|
||||
(!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0);
|
||||
(!backendIsRunning &&
|
||||
currentRunningTasks.length > 0 &&
|
||||
backendRunningFeatures.length === 0);
|
||||
|
||||
if (needsSync) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
if (backendIsRunning !== currentIsRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
@@ -206,7 +243,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
}, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]);
|
||||
}, [branchName, currentProject, setAutoModeRunning]);
|
||||
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
@@ -281,8 +318,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
|
||||
? event.maxConcurrency
|
||||
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
|
||||
// Always apply start events even during transitions - this confirms the optimistic state
|
||||
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
|
||||
}
|
||||
// If we were in a restart transition (concurrency change), the arrival of
|
||||
// auto_mode_started confirms the restart is complete. Clear the transition
|
||||
// flags so future auto_mode_stopped events are processed normally.
|
||||
// Only clear transition refs when the event is for this hook's worktree,
|
||||
// to avoid events for worktree B incorrectly affecting worktree A's state.
|
||||
if (isRestartTransitionRef.current && eventBranchName === branchName) {
|
||||
logger.debug(`[AutoMode] Restart transition complete for ${worktreeDesc}`);
|
||||
isTransitioningRef.current = false;
|
||||
isRestartTransitionRef.current = false;
|
||||
if (restartSafetyTimeoutRef.current) {
|
||||
clearTimeout(restartSafetyTimeoutRef.current);
|
||||
restartSafetyTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -307,12 +359,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
break;
|
||||
|
||||
case 'auto_mode_stopped':
|
||||
// Backend stopped auto loop - update UI state
|
||||
// Backend stopped auto loop - update UI state.
|
||||
// Skip during transitions (e.g., restartWithConcurrency) to avoid flickering the toggle
|
||||
// off between stop and start. The transition handler will set the correct final state.
|
||||
// Only suppress (and only apply transition guard) when the event is for this hook's
|
||||
// worktree, to avoid worktree B's stop events being incorrectly suppressed by
|
||||
// worktree A's transition state.
|
||||
{
|
||||
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
|
||||
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, eventBranchName, false);
|
||||
if (eventBranchName === branchName && isTransitioningRef.current) {
|
||||
logger.info(
|
||||
`[AutoMode] Backend stopped auto loop for ${worktreeDesc} (ignored during transition)`
|
||||
);
|
||||
} else {
|
||||
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, eventBranchName, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -574,6 +637,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
return unsubscribe;
|
||||
}, [
|
||||
projectId,
|
||||
branchName,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
addAutoModeActivity,
|
||||
@@ -582,7 +646,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
setAutoModeRunning,
|
||||
currentProject?.path,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
@@ -624,8 +687,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
}
|
||||
|
||||
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
|
||||
// Sync with backend after success (gets runningFeatures if events were delayed)
|
||||
queueMicrotask(() => void refreshStatus());
|
||||
// Sync with backend after a short delay to get runningFeatures if events were delayed.
|
||||
// The delay ensures the backend has fully processed the start before we poll status,
|
||||
// avoiding a race where status returns stale data and briefly flickers the toggle.
|
||||
setTimeout(() => void refreshStatus(), 500);
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
@@ -635,7 +700,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
} finally {
|
||||
isTransitioningRef.current = false;
|
||||
}
|
||||
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
|
||||
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree, refreshStatus]);
|
||||
|
||||
// Stop auto mode - calls backend to stop the auto loop for this worktree
|
||||
const stop = useCallback(async () => {
|
||||
@@ -672,8 +737,8 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// NOTE: Running tasks will continue until natural completion.
|
||||
// The backend stops picking up new features but doesn't abort running ones.
|
||||
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
|
||||
// Sync with backend after success
|
||||
queueMicrotask(() => void refreshStatus());
|
||||
// Sync with backend after a short delay to confirm stopped state
|
||||
setTimeout(() => void refreshStatus(), 500);
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||
@@ -683,7 +748,95 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
} finally {
|
||||
isTransitioningRef.current = false;
|
||||
}
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
}, [currentProject, branchName, setAutoModeRunning, refreshStatus]);
|
||||
|
||||
// Restart auto mode with new concurrency without flickering the toggle.
|
||||
// Unlike stop() + start(), this keeps isRunning=true throughout the transition
|
||||
// so the toggle switch never visually turns off.
|
||||
//
|
||||
// IMPORTANT: isTransitioningRef is NOT cleared in the finally block here.
|
||||
// Instead, it stays true until the auto_mode_started WebSocket event arrives,
|
||||
// which confirms the backend restart is complete. This prevents a race condition
|
||||
// where a delayed auto_mode_stopped WebSocket event (sent by the backend during
|
||||
// stop()) arrives after the HTTP calls complete but before the WebSocket events,
|
||||
// which would briefly set isRunning=false and cause a visible toggle flicker.
|
||||
// A safety timeout ensures the flag is cleared even if the event never arrives.
|
||||
const restartWithConcurrency = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any previous safety timeout
|
||||
if (restartSafetyTimeoutRef.current) {
|
||||
clearTimeout(restartSafetyTimeoutRef.current);
|
||||
restartSafetyTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
isTransitioningRef.current = true;
|
||||
isRestartTransitionRef.current = true;
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.stop || !api?.autoMode?.start) {
|
||||
throw new Error('Auto mode API not available');
|
||||
}
|
||||
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Restarting with new concurrency for ${worktreeDesc} in ${currentProject.path}`
|
||||
);
|
||||
|
||||
// Stop backend without updating UI state (keep isRunning=true)
|
||||
const stopResult = await api.autoMode.stop(currentProject.path, branchName);
|
||||
|
||||
if (!stopResult.success) {
|
||||
logger.error('Failed to stop auto mode during restart:', stopResult.error);
|
||||
// Don't throw - try to start anyway since the goal is to update concurrency
|
||||
}
|
||||
|
||||
// Start backend with the new concurrency (UI state stays isRunning=true)
|
||||
const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
|
||||
const startResult = await api.autoMode.start(
|
||||
currentProject.path,
|
||||
branchName,
|
||||
currentMaxConcurrency
|
||||
);
|
||||
|
||||
if (!startResult.success) {
|
||||
// If start fails, we need to revert UI state since we're actually stopped now
|
||||
isTransitioningRef.current = false;
|
||||
isRestartTransitionRef.current = false;
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
logger.error('Failed to restart auto mode with new concurrency:', startResult.error);
|
||||
throw new Error(startResult.error || 'Failed to restart auto mode');
|
||||
}
|
||||
|
||||
logger.debug(`[AutoMode] Restarted successfully for ${worktreeDesc}`);
|
||||
|
||||
// Don't clear isTransitioningRef here - let the auto_mode_started WebSocket
|
||||
// event handler clear it. Set a safety timeout in case the event never arrives.
|
||||
restartSafetyTimeoutRef.current = setTimeout(() => {
|
||||
if (isRestartTransitionRef.current) {
|
||||
logger.warn('[AutoMode] Restart transition safety timeout - clearing transition flag');
|
||||
isTransitioningRef.current = false;
|
||||
isRestartTransitionRef.current = false;
|
||||
restartSafetyTimeoutRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
// On error, clear the transition flags immediately
|
||||
isTransitioningRef.current = false;
|
||||
isRestartTransitionRef.current = false;
|
||||
// Revert UI state since the backend may be stopped after a partial restart
|
||||
if (currentProject) {
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||
setAutoModeRunning(currentProject.id, branchName, false);
|
||||
}
|
||||
logger.error('Error restarting auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
|
||||
|
||||
// Stop a specific feature
|
||||
const stopFeature = useCallback(
|
||||
@@ -731,6 +884,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
start,
|
||||
stop,
|
||||
stopFeature,
|
||||
restartWithConcurrency,
|
||||
refreshStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
defaultSkipTests: state.defaultSkipTests as boolean,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking as boolean,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean,
|
||||
mergePostAction: (state.mergePostAction as 'commit' | 'manual' | null) ?? null,
|
||||
useWorktrees: state.useWorktrees as boolean,
|
||||
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||
@@ -704,6 +705,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
defaultSkipTests: settings.defaultSkipTests ?? true,
|
||||
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
|
||||
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
|
||||
mergePostAction: settings.mergePostAction ?? null,
|
||||
useWorktrees: settings.useWorktrees ?? true,
|
||||
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
|
||||
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
||||
@@ -718,6 +720,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
||||
validationModel: settings.validationModel ?? 'claude-opus',
|
||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
|
||||
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefaultModel,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
@@ -749,6 +753,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
projectHistory: settings.projectHistory ?? [],
|
||||
projectHistoryIndex: settings.projectHistoryIndex ?? -1,
|
||||
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
|
||||
currentWorktreeByProject: settings.currentWorktreeByProject ?? {},
|
||||
// UI State
|
||||
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: settings.lastProjectDir ?? '',
|
||||
@@ -802,6 +807,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||
mergePostAction: state.mergePostAction,
|
||||
useWorktrees: state.useWorktrees,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
@@ -812,6 +818,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
enhancementModel: state.enhancementModel,
|
||||
validationModel: state.validationModel,
|
||||
phaseModels: state.phaseModels,
|
||||
defaultThinkingLevel: state.defaultThinkingLevel,
|
||||
defaultReasoningEffort: state.defaultReasoningEffort,
|
||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||
disabledProviders: state.disabledProviders,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
@@ -836,6 +844,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
projectHistory: state.projectHistory,
|
||||
projectHistoryIndex: state.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
currentWorktreeByProject: state.currentWorktreeByProject,
|
||||
worktreePanelCollapsed: state.worktreePanelCollapsed,
|
||||
lastProjectDir: state.lastProjectDir,
|
||||
recentFolders: state.recentFolders,
|
||||
|
||||
@@ -58,6 +58,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'defaultSkipTests',
|
||||
'enableDependencyBlocking',
|
||||
'skipVerificationInAutoMode',
|
||||
'mergePostAction',
|
||||
'useWorktrees',
|
||||
'defaultPlanningMode',
|
||||
'defaultRequirePlanApproval',
|
||||
@@ -717,6 +718,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
defaultSkipTests: serverSettings.defaultSkipTests,
|
||||
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
|
||||
mergePostAction: serverSettings.mergePostAction ?? null,
|
||||
useWorktrees: serverSettings.useWorktrees,
|
||||
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||
|
||||
145
apps/ui/src/lib/clipboard-utils.ts
Normal file
145
apps/ui/src/lib/clipboard-utils.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Clipboard utility functions with fallbacks for non-HTTPS (insecure) contexts.
|
||||
*
|
||||
* The modern Clipboard API (`navigator.clipboard`) requires a Secure Context (HTTPS).
|
||||
* When running on HTTP, these APIs are unavailable or throw errors.
|
||||
* This module provides `writeToClipboard` and `readFromClipboard` that automatically
|
||||
* fall back to the legacy `document.execCommand` approach using a hidden textarea.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check whether the modern Clipboard API is available.
|
||||
* It requires a secure context (HTTPS or localhost) and the API to exist.
|
||||
*/
|
||||
function isClipboardApiAvailable(): boolean {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.clipboard &&
|
||||
typeof navigator.clipboard.writeText === 'function' &&
|
||||
typeof navigator.clipboard.readText === 'function' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.isSecureContext !== false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write text to the clipboard using the modern Clipboard API with a
|
||||
* fallback to `document.execCommand('copy')` for insecure contexts.
|
||||
*
|
||||
* @param text - The text to write to the clipboard.
|
||||
* @returns `true` if the text was successfully copied; `false` otherwise.
|
||||
*/
|
||||
export async function writeToClipboard(text: string): Promise<boolean> {
|
||||
// Try the modern Clipboard API first
|
||||
if (isClipboardApiAvailable()) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to legacy approach
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback using a hidden textarea + execCommand
|
||||
return writeToClipboardLegacy(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text from the clipboard using the modern Clipboard API with a
|
||||
* fallback to `document.execCommand('paste')` for insecure contexts.
|
||||
*
|
||||
* Note: The legacy fallback for *reading* is limited. `document.execCommand('paste')`
|
||||
* only works in some browsers (mainly older ones). On modern browsers in insecure
|
||||
* contexts, reading from the clipboard may not be possible at all. In those cases,
|
||||
* this function throws an error so the caller can show an appropriate message.
|
||||
*
|
||||
* @returns The text from the clipboard.
|
||||
* @throws If clipboard reading is not supported or permission is denied.
|
||||
*/
|
||||
export async function readFromClipboard(): Promise<string> {
|
||||
// Try the modern Clipboard API first
|
||||
if (isClipboardApiAvailable()) {
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch (err) {
|
||||
// Check if this is a permission-related error
|
||||
if (err instanceof Error) {
|
||||
// Re-throw permission errors so they propagate to the caller
|
||||
if (err.name === 'NotAllowedError' || err.name === 'NotReadableError') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// For other errors, fall through to legacy approach
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback using a hidden textarea + execCommand
|
||||
return readFromClipboardLegacy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy clipboard write using a hidden textarea and `document.execCommand('copy')`.
|
||||
* This works in both secure and insecure contexts in most browsers.
|
||||
*/
|
||||
function writeToClipboardLegacy(text: string): boolean {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
|
||||
// Prevent scrolling and make invisible
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
const success = document.execCommand('copy');
|
||||
return success;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy clipboard read using a hidden textarea and `document.execCommand('paste')`.
|
||||
* This has very limited browser support. Most modern browsers block this for security.
|
||||
* When it fails, we throw an error to let the caller handle it gracefully.
|
||||
*/
|
||||
function readFromClipboardLegacy(): string {
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
// Prevent scrolling and make invisible
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
|
||||
try {
|
||||
const success = document.execCommand('paste');
|
||||
if (success && textarea.value) {
|
||||
return textarea.value;
|
||||
}
|
||||
throw new Error(
|
||||
'Clipboard paste is not supported in this browser on non-HTTPS sites. ' +
|
||||
'Please use HTTPS or paste manually with keyboard shortcuts.'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Clipboard paste is not supported')) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(
|
||||
'Clipboard paste is not supported in this browser on non-HTTPS sites. ' +
|
||||
'Please use HTTPS or paste manually with keyboard shortcuts.'
|
||||
);
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
@@ -568,6 +568,7 @@ type EventType =
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped'
|
||||
| 'dev-server:url-detected'
|
||||
| 'test-runner:started'
|
||||
| 'test-runner:output'
|
||||
| 'test-runner:completed'
|
||||
@@ -576,13 +577,17 @@ type EventType =
|
||||
/**
|
||||
* Dev server log event payloads for WebSocket streaming
|
||||
*/
|
||||
export interface DevServerStartedEvent {
|
||||
|
||||
/** Shared base for dev server events that carry URL/port information */
|
||||
interface DevServerUrlEvent {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
port: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type DevServerStartedEvent = DevServerUrlEvent;
|
||||
|
||||
export interface DevServerOutputEvent {
|
||||
worktreePath: string;
|
||||
content: string;
|
||||
@@ -597,10 +602,13 @@ export interface DevServerStoppedEvent {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type DevServerUrlDetectedEvent = DevServerUrlEvent;
|
||||
|
||||
export type DevServerLogEvent =
|
||||
| { type: 'dev-server:started'; payload: DevServerStartedEvent }
|
||||
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
||||
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
|
||||
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent }
|
||||
| { type: 'dev-server:url-detected'; payload: DevServerUrlDetectedEvent };
|
||||
|
||||
/**
|
||||
* Test runner event payloads for WebSocket streaming
|
||||
@@ -2204,10 +2212,14 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) =>
|
||||
callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent })
|
||||
);
|
||||
const unsub4 = this.subscribeToEvent('dev-server:url-detected', (payload) =>
|
||||
callback({ type: 'dev-server:url-detected', payload: payload as DevServerUrlDetectedEvent })
|
||||
);
|
||||
return () => {
|
||||
unsub1();
|
||||
unsub2();
|
||||
unsub3();
|
||||
unsub4();
|
||||
};
|
||||
},
|
||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||
|
||||
@@ -305,6 +305,7 @@ function RootLayoutContent() {
|
||||
sidebarStyle: state.sidebarStyle,
|
||||
worktreePanelCollapsed: state.worktreePanelCollapsed,
|
||||
collapsedNavSections: state.collapsedNavSections,
|
||||
currentWorktreeByProject: state.currentWorktreeByProject,
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
|
||||
@@ -298,6 +298,7 @@ const initialState: AppState = {
|
||||
enableDependencyBlocking: true,
|
||||
skipVerificationInAutoMode: false,
|
||||
enableAiCommitMessages: true,
|
||||
mergePostAction: null,
|
||||
planUseSelectedWorktreeBranch: true,
|
||||
addFeatureUseSelectedWorktreeBranch: false,
|
||||
useWorktrees: true,
|
||||
@@ -362,6 +363,8 @@ const initialState: AppState = {
|
||||
defaultPlanningMode: 'skip' as PlanningMode,
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
||||
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
|
||||
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
|
||||
pendingPlanApproval: null,
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
@@ -1117,6 +1120,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
logger.error('Failed to sync enableAiCommitMessages:', error);
|
||||
}
|
||||
},
|
||||
setMergePostAction: async (action) => {
|
||||
set({ mergePostAction: action });
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { mergePostAction: action });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync mergePostAction:', error);
|
||||
}
|
||||
},
|
||||
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
||||
set({ planUseSelectedWorktreeBranch: enabled });
|
||||
// Sync to server
|
||||
@@ -2313,6 +2326,28 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
|
||||
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }),
|
||||
|
||||
setDefaultThinkingLevel: async (level) => {
|
||||
set({ defaultThinkingLevel: level });
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { defaultThinkingLevel: level });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync defaultThinkingLevel:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultReasoningEffort: async (effort) => {
|
||||
set({ defaultReasoningEffort: effort });
|
||||
// Sync to server
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.put('/api/settings', { defaultReasoningEffort: effort });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync defaultReasoningEffort:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
} from '@automaker/types';
|
||||
|
||||
import type {
|
||||
@@ -127,6 +129,7 @@ export interface AppState {
|
||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
||||
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
|
||||
mergePostAction: 'commit' | 'manual' | null; // User's preferred action after a clean merge (null = ask every time)
|
||||
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
||||
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||
|
||||
@@ -175,6 +178,10 @@ export interface AppState {
|
||||
phaseModels: PhaseModelConfig;
|
||||
favoriteModels: string[];
|
||||
|
||||
// Default thinking/reasoning levels for two-stage model selector primary button
|
||||
defaultThinkingLevel: ThinkingLevel;
|
||||
defaultReasoningEffort: ReasoningEffort;
|
||||
|
||||
// Cursor CLI Settings (global)
|
||||
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
|
||||
cursorDefaultModel: CursorModelId; // Default Cursor model selection
|
||||
@@ -488,6 +495,7 @@ export interface AppActions {
|
||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
|
||||
setMergePostAction: (action: 'commit' | 'manual' | null) => Promise<void>;
|
||||
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
|
||||
@@ -548,6 +556,8 @@ export interface AppActions {
|
||||
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
|
||||
resetPhaseModels: () => Promise<void>;
|
||||
toggleFavoriteModel: (modelId: string) => void;
|
||||
setDefaultThinkingLevel: (level: ThinkingLevel) => void;
|
||||
setDefaultReasoningEffort: (effort: ReasoningEffort) => void;
|
||||
|
||||
// Cursor CLI Settings actions
|
||||
setEnabledCursorModels: (models: CursorModelId[]) => void;
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UICacheState {
|
||||
cachedWorktreePanelCollapsed: boolean;
|
||||
/** Collapsed nav sections */
|
||||
cachedCollapsedNavSections: Record<string, boolean>;
|
||||
/** Selected worktree per project (path + branch) for instant restore on PWA reload */
|
||||
cachedCurrentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
|
||||
}
|
||||
|
||||
interface UICacheActions {
|
||||
@@ -52,19 +54,29 @@ export const useUICacheStore = create<UICacheState & UICacheActions>()(
|
||||
cachedSidebarStyle: 'unified',
|
||||
cachedWorktreePanelCollapsed: false,
|
||||
cachedCollapsedNavSections: {},
|
||||
cachedCurrentWorktreeByProject: {},
|
||||
|
||||
updateFromAppStore: (state) => set(state),
|
||||
}),
|
||||
{
|
||||
name: STORE_NAME,
|
||||
version: 1,
|
||||
version: 2,
|
||||
partialize: (state) => ({
|
||||
cachedProjectId: state.cachedProjectId,
|
||||
cachedSidebarOpen: state.cachedSidebarOpen,
|
||||
cachedSidebarStyle: state.cachedSidebarStyle,
|
||||
cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed,
|
||||
cachedCollapsedNavSections: state.cachedCollapsedNavSections,
|
||||
cachedCurrentWorktreeByProject: state.cachedCurrentWorktreeByProject,
|
||||
}),
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = persistedState as Record<string, unknown>;
|
||||
if (version < 2) {
|
||||
// Migration from v1: add cachedCurrentWorktreeByProject
|
||||
state.cachedCurrentWorktreeByProject = {};
|
||||
}
|
||||
return state as unknown as UICacheState & UICacheActions;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -82,6 +94,7 @@ export function syncUICache(appState: {
|
||||
sidebarStyle?: 'unified' | 'discord';
|
||||
worktreePanelCollapsed?: boolean;
|
||||
collapsedNavSections?: Record<string, boolean>;
|
||||
currentWorktreeByProject?: Record<string, { path: string | null; branch: string }>;
|
||||
}): void {
|
||||
const update: Partial<UICacheState> = {};
|
||||
|
||||
@@ -100,6 +113,9 @@ export function syncUICache(appState: {
|
||||
if ('collapsedNavSections' in appState) {
|
||||
update.cachedCollapsedNavSections = appState.collapsedNavSections;
|
||||
}
|
||||
if ('currentWorktreeByProject' in appState) {
|
||||
update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
useUICacheStore.getState().updateFromAppStore(update);
|
||||
@@ -142,6 +158,15 @@ export function restoreFromUICache(
|
||||
collapsedNavSections: cache.cachedCollapsedNavSections,
|
||||
};
|
||||
|
||||
// Restore last selected worktree per project so the board doesn't
|
||||
// reset to main branch after PWA memory eviction or tab discard.
|
||||
if (
|
||||
cache.cachedCurrentWorktreeByProject &&
|
||||
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0
|
||||
) {
|
||||
stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject;
|
||||
}
|
||||
|
||||
// Restore the project context when the project object is available.
|
||||
// When projects are not yet loaded (empty array), currentProject remains
|
||||
// null and will be properly set later by hydrateStoreFromSettings().
|
||||
|
||||
@@ -572,6 +572,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe-area-aware close button positioning for full-screen mobile dialogs.
|
||||
On mobile, shift the close button down by the safe-area-inset-top so it
|
||||
remains reachable and not hidden behind the notch or Dynamic Island.
|
||||
On sm+ (desktop), use standard top positioning. */
|
||||
.dialog-fullscreen-mobile [data-slot='dialog-close'] {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 0.75rem);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dialog-fullscreen-mobile [data-slot='dialog-close'] {
|
||||
top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe-area-aware top padding for compact dialog headers (p-0 dialogs with own header).
|
||||
Ensures the header content starts below the Dynamic Island / notch on iOS.
|
||||
Used in dev-server-logs and test-logs panels that use p-0 on DialogContent.
|
||||
On sm+ (desktop), the dialog is centered so no safe-area adjustment needed. */
|
||||
.dialog-compact-header-mobile {
|
||||
padding-top: calc(env(safe-area-inset-top, 0px) + 0.75rem);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dialog-compact-header-mobile {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-subtle {
|
||||
@apply backdrop-blur-sm border-white/5;
|
||||
}
|
||||
|
||||
20
apps/ui/src/types/electron.d.ts
vendored
20
apps/ui/src/types/electron.d.ts
vendored
@@ -8,7 +8,7 @@ import type {
|
||||
ZaiUsageResponse,
|
||||
GeminiUsageResponse,
|
||||
} from '@/store/app-store';
|
||||
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types';
|
||||
|
||||
export interface ImageAttachment {
|
||||
id?: string; // Optional - may not be present in messages loaded from server
|
||||
@@ -759,6 +759,10 @@ export interface FileStatus {
|
||||
indexStatus?: string;
|
||||
/** Raw working tree status character from git porcelain format */
|
||||
workTreeStatus?: string;
|
||||
/** Whether this file is involved in a merge operation */
|
||||
isMergeAffected?: boolean;
|
||||
/** Type of merge involvement (e.g. 'both-modified', 'added-by-us', etc.) */
|
||||
mergeType?: string;
|
||||
}
|
||||
|
||||
export interface FileDiffsResult {
|
||||
@@ -767,6 +771,8 @@ export interface FileDiffsResult {
|
||||
files?: FileStatus[];
|
||||
hasChanges?: boolean;
|
||||
error?: string;
|
||||
/** Merge state info, present when a merge/rebase/cherry-pick is in progress */
|
||||
mergeState?: MergeStateInfo;
|
||||
}
|
||||
|
||||
export interface FileDiffResult {
|
||||
@@ -1286,6 +1292,7 @@ export interface WorktreeAPI {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
urlDetected: boolean;
|
||||
}>;
|
||||
};
|
||||
error?: string;
|
||||
@@ -1304,7 +1311,7 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Subscribe to dev server log events (started, output, stopped)
|
||||
// Subscribe to dev server log events (started, output, stopped, url-detected)
|
||||
onDevServerLogEvent: (
|
||||
callback: (
|
||||
event:
|
||||
@@ -1326,6 +1333,15 @@ export interface WorktreeAPI {
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:url-detected';
|
||||
payload: {
|
||||
worktreePath: string;
|
||||
url: string;
|
||||
port: number;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
) => void
|
||||
) => () => void;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user