mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 22:13: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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user