Feature: worktree view customization and stability fixes (#805)

* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-02-23 20:31:25 -08:00
committed by GitHub
parent e7504b247f
commit 0330c70261
72 changed files with 3667 additions and 1173 deletions

View File

@@ -310,6 +310,8 @@ export function SessionManager({
});
if (activeSessionsList.length > 0) {
onSelectSession(activeSessionsList[0].id);
} else {
onSelectSession(null);
}
}
}

View File

@@ -0,0 +1,220 @@
/**
* CodeMirror-based unified diff viewer.
*
* Uses @codemirror/merge's `unifiedMergeView` extension to display a
* syntax-highlighted inline diff between the original and modified file content.
* The viewer is read-only and collapses unchanged regions.
*/
import { useMemo, useRef, useEffect } from 'react';
import { EditorView } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { unifiedMergeView } from '@codemirror/merge';
import { getLanguageExtension } from '@/lib/codemirror-languages';
import { reconstructFilesFromDiff } from '@/lib/diff-utils';
import { cn } from '@/lib/utils';
// Reuse the same syntax highlighting from the code editor
const syntaxColors = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.typeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.className, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.definition(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.operator, color: 'var(--muted-foreground)' },
{ tag: t.bracket, color: 'var(--muted-foreground)' },
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.tagName, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.heading, color: 'var(--foreground)', fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.link, color: 'var(--primary)', textDecoration: 'underline' },
{ tag: t.content, color: 'var(--foreground)' },
{ tag: t.regexp, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.meta, color: 'var(--muted-foreground)' },
]);
const diffViewTheme = EditorView.theme(
{
'&': {
fontSize: '12px',
fontFamily:
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
backgroundColor: 'var(--background)',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily:
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
},
'.cm-content': {
padding: '0',
minHeight: 'auto',
},
'.cm-line': {
padding: '0 0.5rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
borderRight: '1px solid var(--border)',
paddingRight: '0.25rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '3rem',
textAlign: 'right',
paddingRight: '0.5rem',
fontSize: '11px',
},
// --- GitHub-style diff colors (dark mode) ---
// Added/changed lines: green background
'&.cm-merge-b .cm-changedLine': {
backgroundColor: 'rgba(46, 160, 67, 0.15)',
},
// Highlighted text within added/changed lines: stronger green
'&.cm-merge-b .cm-changedText': {
background: 'rgba(46, 160, 67, 0.4)',
},
// Deleted chunk container: red background
'.cm-deletedChunk': {
backgroundColor: 'rgba(248, 81, 73, 0.1)',
paddingLeft: '6px',
},
// Individual deleted lines within the chunk
'.cm-deletedChunk .cm-deletedLine': {
backgroundColor: 'rgba(248, 81, 73, 0.15)',
},
// Highlighted text within deleted lines: stronger red
'.cm-deletedChunk .cm-deletedText': {
background: 'rgba(248, 81, 73, 0.4)',
},
// Remove strikethrough from deleted text (GitHub doesn't use it)
'.cm-insertedLine, .cm-deletedLine, .cm-deletedLine del': {
textDecoration: 'none',
},
// Gutter markers for changed lines (green bar)
'&.cm-merge-b .cm-changedLineGutter': {
background: '#3fb950',
},
// Gutter markers for deleted lines (red bar)
'.cm-deletedLineGutter': {
background: '#f85149',
},
// Collapse button styling
'.cm-collapsedLines': {
color: 'var(--muted-foreground)',
backgroundColor: 'var(--muted)',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
cursor: 'pointer',
padding: '2px 8px',
fontSize: '11px',
},
// Selection styling
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
},
{ dark: true }
);
interface CodeMirrorDiffViewProps {
/** The unified diff text for a single file */
fileDiff: string;
/** File path for language detection */
filePath: string;
/** Max height of the diff view (CSS value) */
maxHeight?: string;
className?: string;
}
export function CodeMirrorDiffView({
fileDiff,
filePath,
maxHeight = '400px',
className,
}: CodeMirrorDiffViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const { oldContent, newContent } = useMemo(() => reconstructFilesFromDiff(fileDiff), [fileDiff]);
const extensions = useMemo(() => {
const exts: Extension[] = [
EditorView.darkTheme.of(true),
diffViewTheme,
syntaxHighlighting(syntaxColors),
EditorView.editable.of(false),
EditorState.readOnly.of(true),
EditorView.lineWrapping,
unifiedMergeView({
original: oldContent,
highlightChanges: true,
gutter: true,
syntaxHighlightDeletions: true,
mergeControls: false,
collapseUnchanged: { margin: 3, minSize: 4 },
}),
];
const langExt = getLanguageExtension(filePath);
if (langExt) {
exts.push(langExt);
}
return exts;
}, [oldContent, filePath]);
useEffect(() => {
if (!containerRef.current) return;
// Clean up previous view
if (viewRef.current) {
viewRef.current.destroy();
viewRef.current = null;
}
const state = EditorState.create({
doc: newContent,
extensions,
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [newContent, extensions]);
return (
<div ref={containerRef} className={cn('overflow-auto', className)} style={{ maxHeight }} />
);
}

View File

@@ -17,10 +17,13 @@ import {
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import { CodeMirrorDiffView } from '@/components/ui/codemirror-diff-view';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { parseDiff, splitDiffByFile } from '@/lib/diff-utils';
import type { ParsedFileDiff } from '@/lib/diff-utils';
import type { FileStatus, MergeStateInfo } from '@/types/electron';
interface GitDiffPanelProps {
@@ -37,23 +40,6 @@ interface GitDiffPanelProps {
worktreePath?: string;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -129,174 +115,6 @@ function getStagingState(file: FileStatus): 'staged' | 'unstaged' | 'partial' {
return 'unstaged';
}
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// New file diff
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
// New file indicator
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
// Hunk header
if (line.startsWith('@@')) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
// Parse line numbers from @@ -old,count +new,count @@
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
// Don't forget the last file and hunk
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ''}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-2 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
if (state === 'staged') {
return (
@@ -401,6 +219,7 @@ function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
function FileDiffSection({
fileDiff,
rawDiff,
isExpanded,
onToggle,
fileStatus,
@@ -410,6 +229,8 @@ function FileDiffSection({
isStagingFile,
}: {
fileDiff: ParsedFileDiff;
/** Raw unified diff string for this file, used by CodeMirror merge view */
rawDiff?: string;
isExpanded: boolean;
onToggle: () => void;
fileStatus?: FileStatus;
@@ -418,14 +239,8 @@ function FileDiffSection({
onUnstage?: (filePath: string) => void;
isStagingFile?: boolean;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
);
const additions = fileDiff.additions;
const deletions = fileDiff.deletions;
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
@@ -521,20 +336,9 @@ function FileDiffSection({
)}
</div>
</div>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
{isExpanded && rawDiff && (
<div className="bg-background border-t border-border">
<CodeMirrorDiffView fileDiff={rawDiff} filePath={fileDiff.filePath} maxHeight="400px" />
</div>
)}
</div>
@@ -619,6 +423,16 @@ export function GitDiffPanel({
return diffs;
}, [diffContent, mergeState, fileStatusMap]);
// Build a map from file path to raw diff string for CodeMirror merge view
const fileDiffMap = useMemo(() => {
const map = new Map<string, string>();
const perFileDiffs = splitDiffByFile(diffContent);
for (const entry of perFileDiffs) {
map.set(entry.filePath, entry.diff);
}
return map;
}, [diffContent]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
@@ -822,25 +636,9 @@ export function GitDiffPanel({
return { staged, partial, unstaged, total: files.length };
}, [enableStaging, files]);
// Total stats
const totalAdditions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'addition').length,
0
),
0
);
const totalDeletions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
),
0
);
// Total stats (pre-computed by shared parseDiff)
const totalAdditions = parsedDiffs.reduce((acc, file) => acc + file.additions, 0);
const totalDeletions = parsedDiffs.reduce((acc, file) => acc + file.deletions, 0);
return (
<div
@@ -1053,6 +851,7 @@ export function GitDiffPanel({
<FileDiffSection
key={fileDiff.filePath}
fileDiff={fileDiff}
rawDiff={fileDiffMap.get(fileDiff.filePath)}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
fileStatus={fileStatusMap.get(fileDiff.filePath)}

View File

@@ -63,6 +63,7 @@ import {
PlanApprovalDialog,
MergeRebaseDialog,
QuickAddDialog,
ChangePRNumberDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
@@ -198,6 +199,7 @@ export function BoardView() {
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showChangePRNumberDialog, setShowChangePRNumberDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
@@ -1030,7 +1032,8 @@ export function BoardView() {
skipTests: defaultSkipTests,
model: resolveModelString(modelEntry.model) as ModelAlias,
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
reasoningEffort: modelEntry.reasoningEffort,
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined,
priority: 2,
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
@@ -1064,7 +1067,8 @@ export function BoardView() {
skipTests: defaultSkipTests,
model: resolveModelString(modelEntry.model) as ModelAlias,
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
reasoningEffort: modelEntry.reasoningEffort,
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined,
priority: 2,
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
@@ -1691,6 +1695,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onChangePRNumber={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowChangePRNumberDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
@@ -2229,6 +2237,18 @@ export function BoardView() {
}}
/>
{/* Change PR Number Dialog */}
<ChangePRNumberDialog
open={showChangePRNumberDialog}
onOpenChange={setShowChangePRNumberDialog}
worktree={selectedWorktreeForAction}
projectPath={currentProject?.path || null}
onChanged={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Create Branch Dialog */}
<CreateBranchDialog
open={showCreateBranchDialog}

View File

@@ -28,7 +28,11 @@ import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
import {
supportsReasoningEffort,
isAdaptiveThinkingModel,
getThinkingLevelsForModel,
} from '@automaker/types';
import {
PrioritySelector,
WorkModeSelector,
@@ -211,6 +215,7 @@ export function AddFeatureDialog({
defaultRequirePlanApproval,
useWorktrees,
defaultFeatureModel,
defaultThinkingLevel,
currentProject,
} = useAppStore();
@@ -240,7 +245,22 @@ export function AddFeatureDialog({
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry(effectiveDefaultFeatureModel);
// Apply defaultThinkingLevel from settings to the model entry.
// This ensures the "Quick-Select Defaults" thinking level setting is respected
// even when the user doesn't change the model in the dropdown.
const modelId =
typeof effectiveDefaultFeatureModel.model === 'string'
? effectiveDefaultFeatureModel.model
: '';
const availableLevels = getThinkingLevelsForModel(modelId);
const effectiveThinkingLevel = availableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: availableLevels[0];
setModelEntry({
...effectiveDefaultFeatureModel,
thinkingLevel: effectiveThinkingLevel,
});
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
@@ -269,6 +289,7 @@ export function AddFeatureDialog({
defaultPlanningMode,
defaultRequirePlanApproval,
effectiveDefaultFeatureModel,
defaultThinkingLevel,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
@@ -394,7 +415,19 @@ export function AddFeatureDialog({
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry(effectiveDefaultFeatureModel);
// Apply defaultThinkingLevel to the model entry (same logic as dialog open)
const resetModelId =
typeof effectiveDefaultFeatureModel.model === 'string'
? effectiveDefaultFeatureModel.model
: '';
const resetAvailableLevels = getThinkingLevelsForModel(resetModelId);
const resetThinkingLevel = resetAvailableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: resetAvailableLevels[0];
setModelEntry({
...effectiveDefaultFeatureModel,
thinkingLevel: resetThinkingLevel,
});
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
pr?: {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
};
}
interface ChangePRNumberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
projectPath: string | null;
onChanged: () => void;
}
export function ChangePRNumberDialog({
open,
onOpenChange,
worktree,
projectPath,
onChanged,
}: ChangePRNumberDialogProps) {
const [prNumberInput, setPrNumberInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize with current PR number when dialog opens
useEffect(() => {
if (open && worktree?.pr?.number) {
setPrNumberInput(String(worktree.pr.number));
} else if (open) {
setPrNumberInput('');
}
setError(null);
}, [open, worktree]);
const handleSubmit = useCallback(async () => {
if (!worktree) return;
const trimmed = prNumberInput.trim();
if (!/^\d+$/.test(trimmed)) {
setError('Please enter a valid positive PR number');
return;
}
const prNumber = Number(trimmed);
if (prNumber <= 0) {
setError('Please enter a valid positive PR number');
return;
}
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.updatePRNumber) {
setError('Worktree API not available');
return;
}
const result = await api.worktree.updatePRNumber(
worktree.path,
prNumber,
projectPath || undefined
);
if (result.success) {
const prInfo = result.result?.prInfo;
toast.success('PR tracking updated', {
description: prInfo?.title
? `Now tracking PR #${prNumber}: ${prInfo.title}`
: `Now tracking PR #${prNumber}`,
});
onOpenChange(false);
onChanged();
} else {
setError(result.error || 'Failed to update PR number');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update PR number');
} finally {
setIsLoading(false);
}
}, [worktree, prNumberInput, projectPath, onOpenChange, onChanged]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading) {
e.preventDefault();
handleSubmit();
}
},
[isLoading, handleSubmit]
);
if (!worktree) return null;
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isLoading) {
onOpenChange(isOpen);
}
}}
>
<DialogContent className="sm:max-w-[400px]" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
Change Tracked PR Number
</DialogTitle>
<DialogDescription>
Update which pull request number is tracked for{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
{worktree.pr && (
<span className="block mt-1 text-xs">
Currently tracking PR #{worktree.pr.number}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-2 space-y-3">
<div className="space-y-2">
<Label htmlFor="pr-number">Pull Request Number</Label>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">#</span>
<Input
id="pr-number"
type="text"
inputMode="numeric"
placeholder="e.g. 42"
value={prNumberInput}
onChange={(e) => {
setPrNumberInput(e.target.value);
setError(null);
}}
disabled={isLoading}
autoFocus
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
Enter the GitHub PR number to associate with this worktree. The PR info will be
fetched from GitHub if available.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !prNumberInput.trim()}>
{isLoading ? (
<>
<Spinner size="xs" className="mr-2" />
Updating...
</>
) : (
<>
<GitPullRequest className="w-4 h-4 mr-2" />
Update PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -27,6 +27,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { resolveModelString } from '@automaker/model-resolver';
interface RemoteInfo {
name: string;
@@ -313,7 +314,7 @@ export function CreatePRDialog({
const result = await api.worktree.generatePRDescription(
worktree.path,
branchNameForApi,
prDescriptionModelOverride.effectiveModel,
resolveModelString(prDescriptionModelOverride.effectiveModel),
prDescriptionModelOverride.effectiveModelEntry.thinkingLevel,
prDescriptionModelOverride.effectiveModelEntry.providerId
);
@@ -501,7 +502,7 @@ export function CreatePRDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px] flex flex-col">
<DialogContent className="sm:max-w-[550px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />

View File

@@ -25,6 +25,7 @@ export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';
export { ChangePRNumberDialog } from './change-pr-number-dialog';
export {
BranchConflictDialog,
type BranchConflictData,

View File

@@ -1,5 +1,6 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Feature,
FeatureImage,
@@ -18,11 +19,29 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardActions');
const MAX_DUPLICATES = 50;
/**
* Removes a running task from all worktrees for a given project.
* Used when stopping features to ensure the task is removed from all worktree contexts,
* not just the current one.
*/
function removeRunningTaskFromAllWorktrees(projectId: string, featureId: string): void {
const store = useAppStore.getState();
const prefix = `${projectId}::`;
for (const [key, worktreeState] of Object.entries(store.autoModeByWorktree)) {
if (key.startsWith(prefix) && worktreeState.runningTasks?.includes(featureId)) {
const branchPart = key.slice(prefix.length);
const branch = branchPart === '__main__' ? null : branchPart;
store.removeRunningTask(projectId, branch, featureId);
}
}
}
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
features: Feature[];
@@ -84,6 +103,8 @@ export function useBoardActions({
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const queryClient = useQueryClient();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
@@ -503,6 +524,10 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
// Remove from all worktrees
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, featureId);
}
toast.success('Agent stopped', {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
@@ -533,7 +558,7 @@ export function useBoardActions({
removeFeature(featureId);
await persistFeatureDelete(featureId);
},
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject]
);
const handleRunFeature = useCallback(
@@ -999,6 +1024,31 @@ export function useBoardActions({
? 'waiting_approval'
: 'backlog';
// Remove the running task from ALL worktrees for this project.
// autoMode.stopFeature only removes from its scoped worktree (branchName),
// but the feature may be tracked under a different worktree branch.
// Without this, runningAutoTasksAllWorktrees still contains the feature
// and the board column logic forces it into in_progress.
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
// Optimistically update the React Query features cache so the board
// moves the card immediately. Without this, the card stays in
// "in_progress" until the next poll cycle (30s) because the async
// refetch races with the persistFeatureUpdate write.
if (currentProject) {
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
(oldFeatures: Feature[] | undefined) => {
if (!oldFeatures) return oldFeatures;
return oldFeatures.map((f) =>
f.id === feature.id ? { ...f, status: targetStatus } : f
);
}
);
}
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
// Must await to ensure file is written before user can restart
@@ -1020,7 +1070,7 @@ export function useBoardActions({
});
}
},
[autoMode, moveFeature, persistFeatureUpdate]
[autoMode, moveFeature, persistFeatureUpdate, currentProject, queryClient]
);
const handleStartNextFeatures = useCallback(async () => {
@@ -1137,6 +1187,12 @@ export function useBoardActions({
})
)
);
// Remove from all worktrees
if (currentProject) {
for (const feature of runningVerified) {
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
}
}
// Use bulk update API for a single server request instead of N individual calls

View File

@@ -6,13 +6,21 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
import {
EnhancementMode,
ENHANCEMENT_MODE_LABELS,
REWRITE_MODES,
ADDITIVE_MODES,
isAdditiveMode,
} from './enhancement-constants';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('EnhanceWithAI');
@@ -79,7 +87,10 @@ export function EnhanceWithAI({
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
// For additive modes, prepend the original description above the AI-generated content
const enhancedText = isAdditiveMode(enhancementMode)
? `${originalText.trim()}\n\n${result.enhancedText.trim()}`
: result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
@@ -119,13 +130,19 @@ export function EnhanceWithAI({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
<DropdownMenuLabel>Rewrite</DropdownMenuLabel>
{REWRITE_MODES.map((mode) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{ENHANCEMENT_MODE_LABELS[mode]}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Append Details</DropdownMenuLabel>
{ADDITIVE_MODES.map((mode) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{ENHANCEMENT_MODE_LABELS[mode]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,5 +1,5 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
import type { EnhancementMode } from '@automaker/types';
export type { EnhancementMode } from '@automaker/types';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
@@ -18,3 +18,14 @@ export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};
/** Modes that rewrite/replace the entire description */
export const REWRITE_MODES: EnhancementMode[] = ['improve', 'simplify'];
/** Modes that append additional content below the original description */
export const ADDITIVE_MODES: EnhancementMode[] = ['technical', 'acceptance', 'ux-reviewer'];
/** Check if a mode appends content rather than replacing */
export function isAdditiveMode(mode: EnhancementMode): boolean {
return ADDITIVE_MODES.includes(mode);
}

View File

@@ -43,6 +43,9 @@ import {
XCircle,
CheckCircle,
Settings2,
ArrowLeftRight,
Check,
Hash,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -105,6 +108,7 @@ interface WorktreeActionsDropdownProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -149,6 +153,14 @@ interface WorktreeActionsDropdownProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** Available worktrees for swapping into this slot (non-main only) */
availableWorktreesForSwap?: WorktreeInfo[];
/** The slot index for this tab in the pinned list (0-based, excluding main) */
slotIndex?: number;
/** Callback when user swaps this slot to a different worktree */
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
pinnedBranches?: string[];
}
/**
@@ -259,6 +271,7 @@ export function WorktreeActionsDropdown({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -287,6 +300,10 @@ export function WorktreeActionsDropdown({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
availableWorktreesForSwap,
slotIndex,
onSwapWorktree,
pinnedBranches,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -1334,6 +1351,12 @@ export function WorktreeActionsDropdown({
<Zap className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
{onChangePRNumber && (
<DropdownMenuItem onClick={() => onChangePRNumber(worktree)} className="text-xs">
<Hash className="w-3.5 h-3.5 mr-2" />
Change PR Number
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
@@ -1359,6 +1382,36 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Swap Worktree submenu - only shown for non-main slots when there are other worktrees to swap to */}
{!worktree.isMain &&
availableWorktreesForSwap &&
availableWorktreesForSwap.length > 1 &&
slotIndex !== undefined &&
onSwapWorktree && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<ArrowLeftRight className="w-3.5 h-3.5 mr-2" />
Swap Worktree
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-64 max-h-80 overflow-y-auto">
{availableWorktreesForSwap
.filter((wt) => wt.branch !== worktree.branch)
.map((wt) => {
const isPinned = pinnedBranches?.includes(wt.branch);
return (
<DropdownMenuItem
key={wt.path}
onSelect={() => onSwapWorktree(slotIndex, wt.branch)}
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
>
<span className="truncate flex-1">{wt.branch}</span>
{isPinned && <Check className="w-3 h-3 shrink-0 text-muted-foreground" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && (
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}

View File

@@ -102,6 +102,7 @@ export interface WorktreeDropdownProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -148,6 +149,8 @@ export interface WorktreeDropdownProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** When false, the trigger button uses a subdued style instead of the primary highlight. Defaults to true. */
highlightTrigger?: boolean;
}
/**
@@ -215,6 +218,7 @@ export function WorktreeDropdown({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -245,10 +249,13 @@ export function WorktreeDropdown({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
highlightTrigger = true,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select worktree';
const displayBranch =
selectedWorktree?.branch ??
(worktrees.length > 0 ? `+${worktrees.length} more` : 'Select worktree');
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
displayBranch,
MAX_TRIGGER_BRANCH_NAME_LENGTH
@@ -292,15 +299,28 @@ export function WorktreeDropdown({
const triggerButton = useMemo(
() => (
<Button
variant="outline"
variant={selectedWorktree && highlightTrigger ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
'h-7 px-3 gap-1.5 font-mono text-xs min-w-0',
selectedWorktree &&
highlightTrigger &&
'bg-primary text-primary-foreground border-r-0 rounded-l-md rounded-r-none',
selectedWorktree &&
!highlightTrigger &&
'bg-secondary/50 hover:bg-secondary border-r-0 rounded-l-md rounded-r-none',
!selectedWorktree && 'bg-secondary/50 hover:bg-secondary rounded-md'
)}
disabled={isActivating}
>
{/* Running/Activating indicator */}
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
{(selectedStatus.isRunning || isActivating) && (
<Spinner
size="xs"
className="shrink-0"
variant={selectedWorktree && highlightTrigger ? 'foreground' : 'primary'}
/>
)}
{/* Branch icon */}
<GitBranch className="w-3.5 h-3.5 shrink-0" />
@@ -403,7 +423,14 @@ export function WorktreeDropdown({
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
</Button>
),
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
[
isActivating,
selectedStatus,
truncatedBranch,
selectedWorktree,
branchCardCounts,
highlightTrigger,
]
);
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
@@ -490,7 +517,7 @@ export function WorktreeDropdown({
{selectedWorktree?.isMain && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
isSelected={highlightTrigger}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -507,7 +534,7 @@ export function WorktreeDropdown({
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
isSelected={highlightTrigger}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
@@ -541,6 +568,7 @@ export function WorktreeDropdown({
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}

View File

@@ -66,6 +66,7 @@ interface WorktreeTabProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -118,6 +119,14 @@ interface WorktreeTabProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** Available worktrees for swapping into this slot (non-main only) */
availableWorktreesForSwap?: WorktreeInfo[];
/** The slot index for this tab in the pinned list (0-based, excluding main) */
slotIndex?: number;
/** Callback when user swaps this slot to a different worktree */
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
pinnedBranches?: string[];
}
export function WorktreeTab({
@@ -164,6 +173,7 @@ export function WorktreeTab({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -196,6 +206,10 @@ export function WorktreeTab({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
availableWorktreesForSwap,
slotIndex,
onSwapWorktree,
pinnedBranches,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -542,6 +556,7 @@ export function WorktreeTab({
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
@@ -570,6 +585,10 @@ export function WorktreeTab({
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
availableWorktreesForSwap={availableWorktreesForSwap}
slotIndex={slotIndex}
onSwapWorktree={onSwapWorktree}
pinnedBranches={pinnedBranches}
/>
</div>
);

View File

@@ -120,6 +120,7 @@ export interface WorktreePanelProps {
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;

View File

@@ -26,11 +26,11 @@ import {
} from './hooks';
import {
WorktreeTab,
WorktreeDropdown,
DevServerLogsPanel,
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import {
@@ -50,8 +50,9 @@ import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
const WORKTREE_DROPDOWN_THRESHOLD = 3;
// Stable empty array to avoid creating a new [] reference on every render
// when pinnedWorktreeBranchesByProject[projectPath] is undefined
const EMPTY_BRANCHES: string[] = [];
export function WorktreePanel({
projectPath,
@@ -59,6 +60,7 @@ export function WorktreePanel({
onDeleteWorktree,
onCommit,
onCreatePR,
onChangePRNumber,
onCreateBranch,
onAddressPRComments,
onAutoAddressPRComments,
@@ -99,7 +101,6 @@ export function WorktreePanel({
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
remotesWithBranch,
isLoadingBranches,
@@ -139,13 +140,107 @@ export function WorktreePanel({
features,
});
// Pinned worktrees count from store
const pinnedWorktreesCount = useAppStore(
(state) => state.pinnedWorktreesCountByProject[projectPath] ?? 0
);
const pinnedWorktreeBranchesRaw = useAppStore(
(state) => state.pinnedWorktreeBranchesByProject[projectPath]
);
const pinnedWorktreeBranches = pinnedWorktreeBranchesRaw ?? EMPTY_BRANCHES;
const setPinnedWorktreeBranches = useAppStore((state) => state.setPinnedWorktreeBranches);
const swapPinnedWorktreeBranch = useAppStore((state) => state.swapPinnedWorktreeBranch);
// Resolve pinned worktrees from explicit branch assignments
// Shows exactly pinnedWorktreesCount slots, each with a specific worktree.
// Main worktree is always slot 0. Other slots can be swapped by the user.
const pinnedWorktrees = useMemo(() => {
const mainWt = worktrees.find((w) => w.isMain);
const otherWts = worktrees.filter((w) => !w.isMain);
// Slot 0 is always main worktree
const result: WorktreeInfo[] = mainWt ? [mainWt] : [];
// pinnedWorktreesCount represents only non-main worktrees; main is always shown separately
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
if (otherSlotCount > 0 && otherWts.length > 0) {
// Use explicit branch assignments if available
const assignedBranches = pinnedWorktreeBranches;
const usedBranches = new Set<string>();
for (let i = 0; i < otherSlotCount; i++) {
const assignedBranch = assignedBranches[i];
let wt: WorktreeInfo | undefined;
// Try to find the explicitly assigned worktree
if (assignedBranch) {
wt = otherWts.find((w) => w.branch === assignedBranch && !usedBranches.has(w.branch));
}
// Fall back to next available worktree if assigned one doesn't exist
if (!wt) {
wt = otherWts.find((w) => !usedBranches.has(w.branch));
}
if (wt) {
result.push(wt);
usedBranches.add(wt.branch);
}
}
}
return result;
}, [worktrees, pinnedWorktreesCount, pinnedWorktreeBranches]);
// All non-main worktrees available for swapping into slots
const availableWorktreesForSwap = useMemo(() => {
return worktrees.filter((w) => !w.isMain);
}, [worktrees]);
// Handle swapping a worktree in a specific slot
const handleSwapWorktreeSlot = useCallback(
(slotIndex: number, newBranch: string) => {
swapPinnedWorktreeBranch(projectPath, slotIndex, newBranch);
},
[projectPath, swapPinnedWorktreeBranch]
);
// Initialize pinned branch assignments when worktrees change
// This ensures new worktrees get default slot assignments
// Read store state directly inside the effect to avoid a dependency cycle
// (the effect writes to the same state it would otherwise depend on)
useEffect(() => {
const mainWt = worktrees.find((w) => w.isMain);
const otherWts = worktrees.filter((w) => !w.isMain);
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
const storedBranches = useAppStore.getState().pinnedWorktreeBranchesByProject[projectPath];
if (otherSlotCount > 0 && otherWts.length > 0) {
const existing = storedBranches ?? [];
if (existing.length < otherSlotCount) {
const used = new Set(existing.filter(Boolean));
const filled = [...existing];
for (const wt of otherWts) {
if (filled.length >= otherSlotCount) break;
if (!used.has(wt.branch)) {
filled.push(wt.branch);
used.add(wt.branch);
}
}
if (filled.length > 0) {
setPinnedWorktreeBranches(projectPath, filled);
}
}
}
}, [worktrees, pinnedWorktreesCount, projectPath, setPinnedWorktreeBranches]);
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
@@ -651,18 +746,6 @@ export function WorktreePanel({
// Keep logPanelWorktree set for smooth close animation
}, []);
// Wrap handleStartDevServer to auto-open the logs panel so the user
// can see output immediately (including failure reasons)
const handleStartDevServerAndShowLogs = useCallback(
async (worktree: WorktreeInfo) => {
// Open logs panel immediately so output is visible from the start
setLogPanelWorktree(worktree);
setLogPanelOpen(true);
await handleStartDevServer(worktree);
},
[handleStartDevServer]
);
// Handle opening the push to remote dialog
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
setPushToRemoteWorktree(worktree);
@@ -887,7 +970,6 @@ export function WorktreePanel({
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Mobile view: single dropdown for all worktrees
if (isMobile) {
@@ -965,12 +1047,13 @@ export function WorktreePanel({
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
@@ -1145,56 +1228,124 @@ export function WorktreePanel({
);
}
// Use dropdown layout when worktree count meets or exceeds the threshold
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
// Desktop view: pinned worktrees as individual tabs (each slot can be swapped)
// Desktop view: full tabs layout or dropdown layout depending on worktree count
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
</span>
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground mr-2 shrink-0">Worktree:</span>
{/* Dropdown layout for 3+ worktrees */}
{useDropdownLayout ? (
<>
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
{/* When only 1 pinned slot (main only) and there are other worktrees,
use a compact dropdown to switch between them without highlighting main */}
{pinnedWorktreesCount === 0 && availableWorktreesForSwap.length > 0 ? (
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
getTrackingRemote={getTrackingRemote}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
highlightTrigger={false}
/>
) : pinnedWorktreesCount === 0 ? (
/* Only main worktree, no others exist - render main tab without highlight */
mainWorktree && (
<WorktreeTab
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={false}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
// Branch switching props
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
// Action dropdown props
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
getTrackingRemote={getTrackingRemote}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
@@ -1205,7 +1356,7 @@ export function WorktreePanel({
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
remotesCache={remotesCache}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1214,12 +1365,13 @@ export function WorktreePanel({
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
@@ -1233,247 +1385,138 @@ export function WorktreePanel({
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
</>
)
) : (
/* Standard tabs layout for 1-2 worktrees */
/* Multiple pinned slots - show individual tabs */
pinnedWorktrees.map((worktree, index) => {
const hasOtherWorktrees = worktrees.length > 1;
const effectiveIsSelected =
isWorktreeSelected(worktree) && (hasOtherWorktrees || !worktree.isMain);
// Slot index for swap (0-based, excluding main which is always slot 0)
const slotIndex = worktree.isMain ? -1 : index - (pinnedWorktrees[0]?.isMain ? 1 : 0);
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={branchCardCounts?.[worktree.branch]}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={effectiveIsSelected}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
availableWorktreesForSwap={!worktree.isMain ? availableWorktreesForSwap : undefined}
slotIndex={slotIndex >= 0 ? slotIndex : undefined}
onSwapWorktree={slotIndex >= 0 ? handleSwapWorktreeSlot : undefined}
pinnedBranches={pinnedWorktrees.map((w) => w.branch)}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
/>
);
})
)}
{/* Create and refresh buttons */}
{useWorktreesEnabled && (
<>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
{/* Worktrees section - only show if enabled and not using dropdown layout */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}

View File

@@ -1,34 +1,13 @@
import { useMemo, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { EditorView, keymap } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { EditorView, keymap, Decoration, WidgetType } from '@codemirror/view';
import { Extension, RangeSetBuilder, StateField } from '@codemirror/state';
import { undo as cmUndo, redo as cmRedo } from '@codemirror/commands';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { search, openSearchPanel } from '@codemirror/search';
// Language imports
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { java } from '@codemirror/lang-java';
import { rust } from '@codemirror/lang-rust';
import { cpp } from '@codemirror/lang-cpp';
import { sql } from '@codemirror/lang-sql';
import { php } from '@codemirror/lang-php';
import { xml } from '@codemirror/lang-xml';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { go } from '@codemirror/legacy-modes/mode/go';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { getLanguageExtension } from '@/lib/codemirror-languages';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
@@ -55,6 +34,8 @@ export interface CodeEditorHandle {
undo: () => void;
/** Redoes the last undone edit */
redo: () => void;
/** Returns the current text selection with line range, or null if nothing is selected */
getSelection: () => { text: string; fromLine: number; toLine: number } | null;
}
interface CodeEditorProps {
@@ -72,133 +53,10 @@ interface CodeEditorProps {
className?: string;
/** When true, scrolls the cursor into view (e.g. after virtual keyboard opens) */
scrollCursorIntoView?: boolean;
}
/** Detect language extension based on file extension */
function getLanguageExtension(filePath: string): Extension | null {
const name = filePath.split('/').pop()?.toLowerCase() || '';
const dotIndex = name.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
// Handle files by name first
switch (name) {
case 'dockerfile':
case 'dockerfile.dev':
case 'dockerfile.prod':
return StreamLanguage.define(dockerFile);
case 'makefile':
case 'gnumakefile':
return StreamLanguage.define(shell);
case '.gitignore':
case '.dockerignore':
case '.npmignore':
case '.eslintignore':
return StreamLanguage.define(shell); // close enough for ignore files
case '.env':
case '.env.local':
case '.env.development':
case '.env.production':
return StreamLanguage.define(shell);
}
switch (ext) {
// JavaScript/TypeScript
case 'js':
case 'mjs':
case 'cjs':
return javascript();
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'mts':
case 'cts':
return javascript({ typescript: true });
case 'tsx':
return javascript({ jsx: true, typescript: true });
// Web
case 'html':
case 'htm':
case 'svelte':
case 'vue':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'json':
case 'jsonc':
case 'json5':
return json();
case 'xml':
case 'svg':
case 'xsl':
case 'xslt':
case 'plist':
return xml();
// Markdown
case 'md':
case 'mdx':
case 'markdown':
return markdown();
// Python
case 'py':
case 'pyx':
case 'pyi':
return python();
// Java/Kotlin
case 'java':
case 'kt':
case 'kts':
return java();
// Systems
case 'rs':
return rust();
case 'c':
case 'h':
return cpp();
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
case 'hxx':
return cpp();
case 'go':
return StreamLanguage.define(go);
case 'swift':
return StreamLanguage.define(swift);
// Scripting
case 'rb':
case 'erb':
return StreamLanguage.define(ruby);
case 'php':
return php();
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return StreamLanguage.define(shell);
// Data
case 'sql':
case 'mysql':
case 'pgsql':
return sql();
case 'yaml':
case 'yml':
return StreamLanguage.define(yaml);
case 'toml':
return StreamLanguage.define(toml);
default:
return null; // Plain text fallback
}
/** Raw unified diff string for the file, used to highlight added/removed lines */
diffContent?: string | null;
/** Fires when the text selection state changes (true = non-empty selection) */
onSelectionChange?: (hasSelection: boolean) => void;
}
/** Get a human-readable language name */
@@ -295,6 +153,215 @@ export function getLanguageName(filePath: string): string {
}
}
// ─── Inline Diff Decorations ─────────────────────────────────────────────
/** Parsed diff info: added line numbers and groups of deleted lines with content */
interface DiffInfo {
addedLines: Set<number>;
/**
* Groups of consecutive deleted lines keyed by the new-file line number
* they appear before. E.g. key=3 means the deleted lines were removed
* just before line 3 in the current file.
*/
deletedGroups: Map<number, string[]>;
}
/** Parse a unified diff to extract added lines and groups of deleted lines */
function parseUnifiedDiff(diffContent: string): DiffInfo {
const addedLines = new Set<number>();
const deletedGroups = new Map<number, string[]>();
const lines = diffContent.split('\n');
let currentNewLine = 0;
let inHunk = false;
let pendingDeletions: string[] = [];
const flushDeletions = () => {
if (pendingDeletions.length > 0) {
const existing = deletedGroups.get(currentNewLine);
if (existing) {
existing.push(...pendingDeletions);
} else {
deletedGroups.set(currentNewLine, [...pendingDeletions]);
}
pendingDeletions = [];
}
};
for (const line of lines) {
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ ...
if (line.startsWith('@@')) {
flushDeletions();
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match) {
currentNewLine = parseInt(match[1], 10);
inHunk = true;
}
continue;
}
if (!inHunk) continue;
// Skip diff header lines
if (
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('diff ') ||
line.startsWith('index ')
) {
continue;
}
if (line.startsWith('+')) {
flushDeletions();
addedLines.add(currentNewLine);
currentNewLine++;
} else if (line.startsWith('-')) {
// Accumulate deleted lines to show as a group
pendingDeletions.push(line.substring(1));
} else if (line.startsWith(' ') || line === '') {
flushDeletions();
currentNewLine++;
}
}
flushDeletions();
return { addedLines, deletedGroups };
}
/** Widget that renders a block of deleted lines inline in the editor */
class DeletedLinesWidget extends WidgetType {
constructor(readonly lines: string[]) {
super();
}
toDOM() {
const container = document.createElement('div');
container.className = 'cm-diff-deleted-widget';
container.style.cssText =
'background-color: oklch(0.55 0.22 25 / 0.1); border-left: 3px solid oklch(0.55 0.22 25 / 0.5);';
for (const line of this.lines) {
const lineEl = document.createElement('div');
lineEl.style.cssText =
'text-decoration: line-through; color: oklch(0.55 0.22 25 / 0.8); background-color: oklch(0.55 0.22 25 / 0.15); padding: 0 0.5rem; padding-left: calc(0.5rem - 3px); white-space: pre; font-family: inherit;';
lineEl.textContent = line || ' ';
container.appendChild(lineEl);
}
return container;
}
eq(other: WidgetType) {
if (!(other instanceof DeletedLinesWidget)) return false;
return (
this.lines.length === other.lines.length && this.lines.every((l, i) => l === other.lines[i])
);
}
ignoreEvent() {
return true;
}
}
/** Create a CodeMirror extension that decorates lines based on diff */
function createDiffDecorations(diffContent: string | null | undefined): Extension {
if (!diffContent) {
return [];
}
const { addedLines, deletedGroups } = parseUnifiedDiff(diffContent);
if (addedLines.size === 0 && deletedGroups.size === 0) {
return [];
}
const addedLineDecoration = Decoration.line({
class: 'cm-diff-added-line',
attributes: { style: 'background-color: oklch(0.65 0.2 145 / 0.15);' },
});
const extensions: Extension[] = [];
// Line decorations for added lines
if (addedLines.size > 0) {
extensions.push(
EditorView.decorations.of((view) => {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
for (const lineNum of addedLines) {
if (lineNum >= 1 && lineNum <= doc.lines) {
const linePos = doc.line(lineNum).from;
builder.add(linePos, linePos, addedLineDecoration);
}
}
return builder.finish();
})
);
}
// Widget decorations for deleted line groups.
// Block decorations MUST be provided via a StateField (not a plugin/function).
if (deletedGroups.size > 0) {
const buildDeletedDecorations = (doc: {
lines: number;
line(n: number): { from: number; to: number };
}) => {
const builder = new RangeSetBuilder<Decoration>();
const positions = [...deletedGroups.keys()].sort((a, b) => a - b);
for (const pos of positions) {
const deletedLines = deletedGroups.get(pos)!;
if (pos >= 1 && pos <= doc.lines) {
const linePos = doc.line(pos).from;
builder.add(
linePos,
linePos,
Decoration.widget({
widget: new DeletedLinesWidget(deletedLines),
block: true,
side: -1,
})
);
} else {
const lastLinePos = doc.line(doc.lines).to;
builder.add(
lastLinePos,
lastLinePos,
Decoration.widget({
widget: new DeletedLinesWidget(deletedLines),
block: true,
side: 1,
})
);
}
}
return builder.finish();
};
extensions.push(
StateField.define({
create(state) {
return buildDeletedDecorations(state.doc);
},
update(decorations, tr) {
if (tr.docChanged) {
return decorations.map(tr.changes);
}
return decorations;
},
provide: (f) => EditorView.decorations.from(f),
})
);
}
return extensions;
}
// ─────────────────────────────────────────────────────────────────────────
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
@@ -338,6 +405,8 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
onSave,
className,
scrollCursorIntoView = false,
diffContent,
onSelectionChange,
},
ref
) {
@@ -347,12 +416,17 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
// Stable refs for callbacks to avoid frequent extension rebuilds
const onSaveRef = useRef(onSave);
const onCursorChangeRef = useRef(onCursorChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const lastHasSelectionRef = useRef(false);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
onCursorChangeRef.current = onCursorChange;
}, [onCursorChange]);
useEffect(() => {
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
// Expose imperative methods to parent components
useImperativeHandle(
@@ -381,6 +455,16 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
cmRedo(editorRef.current.view);
}
},
getSelection: () => {
const view = editorRef.current?.view;
if (!view) return null;
const { from, to } = view.state.selection.main;
if (from === to) return null;
const text = view.state.sliceDoc(from, to);
const fromLine = view.state.doc.lineAt(from).number;
const toLine = view.state.doc.lineAt(to).number;
return { text, fromLine, toLine };
},
}),
[]
);
@@ -537,10 +621,20 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
editorTheme,
search(),
EditorView.updateListener.of((update) => {
if (update.selectionSet && onCursorChangeRef.current) {
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
onCursorChangeRef.current(line.number, pos - line.from + 1);
if (update.selectionSet) {
if (onCursorChangeRef.current) {
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
onCursorChangeRef.current(line.number, pos - line.from + 1);
}
if (onSelectionChangeRef.current) {
const { from, to } = update.state.selection.main;
const hasSelection = from !== to;
if (hasSelection !== lastHasSelectionRef.current) {
lastHasSelectionRef.current = hasSelection;
onSelectionChangeRef.current(hasSelection);
}
}
}
}),
];
@@ -572,8 +666,13 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
exts.push(langExt);
}
// Add inline diff decorations if diff content is provided
if (diffContent) {
exts.push(createDiffDecorations(diffContent));
}
return exts;
}, [filePath, wordWrap, tabSize, editorTheme]);
}, [filePath, wordWrap, tabSize, editorTheme, diffContent]);
return (
<div className={cn('h-full w-full', className)}>

View File

@@ -1,4 +1,5 @@
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
import { useRef, useEffect, useCallback } from 'react';
import { X, Circle, MoreHorizontal, Save, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EditorTab } from '../use-file-editor-store';
import {
@@ -84,61 +85,105 @@ export function EditorTabs({
isDirty,
showSaveButton,
}: EditorTabsProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const activeTabRef = useRef<HTMLDivElement>(null);
// Scroll the active tab into view when it changes
useEffect(() => {
if (activeTabRef.current) {
activeTabRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
}, [activeTabId]);
const scrollBy = useCallback((direction: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = direction === 'left' ? -200 : 200;
scrollRef.current.scrollBy({ left: amount, behavior: 'smooth' });
}, []);
if (tabs.length === 0) return null;
return (
<div
className="flex items-center border-b border-border bg-muted/30 overflow-x-auto"
data-testid="editor-tabs"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const fileColor = getFileColor(tab.fileName);
<div className="flex items-center border-b border-border bg-muted/30" data-testid="editor-tabs">
{/* Scroll left arrow */}
<button
onClick={() => scrollBy('left')}
className="shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Scroll tabs left"
>
<ChevronLeft className="w-4 h-4" />
</button>
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] text-sm transition-colors',
isActive
? 'bg-background text-foreground border-b-2 border-b-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
onClick={() => onTabSelect(tab.id)}
title={tab.filePath}
>
{/* Dirty indicator */}
{tab.isDirty ? (
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
) : (
<span className={cn('w-2 h-2 rounded-full shrink-0', fileColor)} />
)}
{/* Scrollable tab area */}
<div
ref={scrollRef}
className="flex items-center overflow-x-auto flex-1 min-w-0 scrollbar-none"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const fileColor = getFileColor(tab.fileName);
{/* File name */}
<span className="truncate">{tab.fileName}</span>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
return (
<div
key={tab.id}
ref={isActive ? activeTabRef : undefined}
className={cn(
'p-0.5 rounded shrink-0 transition-colors',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-60',
'hover:bg-accent'
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] shrink-0 text-sm transition-colors',
isActive
? 'bg-background text-foreground border-b-2 border-b-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
title="Close"
onClick={() => onTabSelect(tab.id)}
title={tab.filePath}
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
{/* Dirty indicator */}
{tab.isDirty ? (
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
) : (
<span
className={cn('w-2 h-2 rounded-full shrink-0', fileColor.replace('text-', 'bg-'))}
/>
)}
{/* File name */}
<span className="truncate">{tab.fileName}</span>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-0.5 rounded shrink-0 transition-colors',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-60',
'hover:bg-accent'
)}
title="Close"
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
</div>
{/* Scroll right arrow */}
<button
onClick={() => scrollBy('right')}
className="shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Scroll tabs right"
>
<ChevronRight className="w-4 h-4" />
</button>
{/* Tab actions: save button (mobile) + close-all dropdown */}
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
<div className="shrink-0 flex items-center px-1 gap-0.5 border-l border-border">
{/* Save button — shown in the tab bar on mobile */}
{showSaveButton && onSave && (
<button

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
File,
Folder,
@@ -31,7 +31,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
import {
useFileEditorStore,
type FileTreeNode,
type EnhancedGitFileStatus,
} from '../use-file-editor-store';
import { useFileBrowser } from '@/contexts/file-browser-context';
interface FileTreeProps {
@@ -105,6 +109,51 @@ function getGitStatusLabel(status: string | undefined): string {
}
}
/** Status priority for determining the "dominant" status on a folder (higher = more prominent) */
const STATUS_PRIORITY: Record<string, number> = {
U: 6, // Conflicted - highest priority
D: 5, // Deleted
A: 4, // Added
M: 3, // Modified
R: 2, // Renamed
C: 2, // Copied
S: 1, // Staged
'?': 0, // Untracked
'!': -1, // Ignored - lowest priority
};
/** Compute aggregated git status info for a folder from the status maps */
function computeFolderGitRollup(
folderPath: string,
gitStatusMap: Map<string, string>,
enhancedGitStatusMap: Map<string, EnhancedGitFileStatus>
): { count: number; dominantStatus: string | null; totalAdded: number; totalRemoved: number } {
const prefix = folderPath + '/';
let count = 0;
let dominantStatus: string | null = null;
let dominantPriority = -2;
let totalAdded = 0;
let totalRemoved = 0;
for (const [filePath, status] of gitStatusMap) {
if (filePath.startsWith(prefix)) {
count++;
const priority = STATUS_PRIORITY[status] ?? -1;
if (priority > dominantPriority) {
dominantPriority = priority;
dominantStatus = status;
}
const enhanced = enhancedGitStatusMap.get(filePath);
if (enhanced) {
totalAdded += enhanced.linesAdded;
totalRemoved += enhanced.linesRemoved;
}
}
}
return { count, dominantStatus, totalAdded, totalRemoved };
}
/**
* Validate a file/folder name for safety.
* Rejects names containing path separators, relative path components,
@@ -281,6 +330,12 @@ function TreeNode({
const linesRemoved = enhancedStatus?.linesRemoved || 0;
const enhancedLabel = enhancedStatus?.statusLabel || statusLabel;
// Folder-level git status rollup
const folderRollup = useMemo(() => {
if (!node.isDirectory) return null;
return computeFolderGitRollup(node.path, gitStatusMap, enhancedGitStatusMap);
}, [node.isDirectory, node.path, gitStatusMap, enhancedGitStatusMap]);
// Drag state
const isDragging = dragState.draggedPaths.includes(node.path);
const isDropTarget = dragState.dropTargetPath === node.path && node.isDirectory;
@@ -385,9 +440,16 @@ function TreeNode({
// Build tooltip with enhanced info
let tooltip = node.name;
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
if (linesAdded > 0 || linesRemoved > 0) {
tooltip += ` +${linesAdded} -${linesRemoved}`;
if (node.isDirectory && folderRollup && folderRollup.count > 0) {
tooltip += ` (${folderRollup.count} changed file${folderRollup.count !== 1 ? 's' : ''})`;
if (folderRollup.totalAdded > 0 || folderRollup.totalRemoved > 0) {
tooltip += ` +${folderRollup.totalAdded} -${folderRollup.totalRemoved}`;
}
} else {
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
if (linesAdded > 0 || linesRemoved > 0) {
tooltip += ` +${linesAdded} -${linesRemoved}`;
}
}
return (
@@ -456,7 +518,33 @@ function TreeNode({
{/* Name */}
<span className="truncate flex-1">{node.name}</span>
{/* Diff stats (lines added/removed) shown inline */}
{/* Folder: modified file count badge and rollup indicator */}
{node.isDirectory && folderRollup && folderRollup.count > 0 && (
<>
<span
className="text-[10px] font-medium shrink-0 px-1 py-0 rounded-full bg-muted text-muted-foreground"
title={`${folderRollup.count} changed file${folderRollup.count !== 1 ? 's' : ''}`}
>
{folderRollup.count}
</span>
<span
className={cn('w-1.5 h-1.5 rounded-full shrink-0', {
'bg-yellow-500': folderRollup.dominantStatus === 'M',
'bg-green-500':
folderRollup.dominantStatus === 'A' || folderRollup.dominantStatus === 'S',
'bg-red-500': folderRollup.dominantStatus === 'D',
'bg-gray-400': folderRollup.dominantStatus === '?',
'bg-gray-600': folderRollup.dominantStatus === '!',
'bg-purple-500': folderRollup.dominantStatus === 'R',
'bg-cyan-500': folderRollup.dominantStatus === 'C',
'bg-orange-500': folderRollup.dominantStatus === 'U',
})}
title={`${folderRollup.dominantStatus ? getGitStatusLabel(folderRollup.dominantStatus) : 'Changed'} (${folderRollup.count})`}
/>
</>
)}
{/* File: diff stats (lines added/removed) shown inline */}
{!node.isDirectory && (linesAdded > 0 || linesRemoved > 0) && (
<span className="flex items-center gap-1 text-[10px] shrink-0 opacity-70">
{linesAdded > 0 && (
@@ -474,8 +562,8 @@ function TreeNode({
</span>
)}
{/* Git status indicator - two-tone badge for staged+unstaged */}
{gitStatus && (
{/* File: git status indicator - two-tone badge for staged+unstaged */}
{!node.isDirectory && gitStatus && (
<span className="flex items-center gap-0 shrink-0">
{isStaged && isUnstaged ? (
// Two-tone badge: staged (green) + unstaged (yellow)

View File

@@ -11,10 +11,17 @@ import {
Undo2,
Redo2,
Settings,
Diff,
FolderKanban,
} from 'lucide-react';
import { createLogger } from '@automaker/utils/logger';
import { resolveModelString } from '@automaker/model-resolver';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { cn, generateUUID, pathsEqual } from '@/lib/utils';
import { queryKeys } from '@/lib/query-keys';
import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { Button } from '@/components/ui/button';
@@ -23,7 +30,6 @@ import {
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import {
@@ -42,6 +48,7 @@ import {
} from './components/markdown-preview';
import { WorktreeDirectoryDropdown } from './components/worktree-directory-dropdown';
import { GitDetailPanel } from './components/git-detail-panel';
import { AddFeatureDialog } from '@/components/views/board-view/dialogs';
const logger = createLogger('FileEditorView');
@@ -111,10 +118,13 @@ interface FileEditorViewProps {
}
export function FileEditorView({ initialPath }: FileEditorViewProps) {
const { currentProject } = useAppStore();
const { currentProject, defaultSkipTests, getCurrentWorktree, worktreesByProject } =
useAppStore();
const currentWorktree = useAppStore((s) =>
currentProject?.path ? (s.currentWorktreeByProject[currentProject.path] ?? null) : null
);
const queryClient = useQueryClient();
const navigate = useNavigate();
// Read persisted editor font settings from app store
const editorFontSize = useAppStore((s) => s.editorFontSize);
const editorFontFamily = useAppStore((s) => s.editorFontFamily);
@@ -131,6 +141,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const editorRef = useRef<CodeEditorHandle>(null);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showActionsPanel, setShowActionsPanel] = useState(false);
const [hasEditorSelection, setHasEditorSelection] = useState(false);
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
const [featureSelectionContext, setFeatureSelectionContext] = useState<string | undefined>();
// Derive the effective working path from the current worktree selection.
// When a worktree is selected (path is non-null), use the worktree path;
@@ -151,7 +164,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
tabSize,
wordWrap,
maxFileSize,
setFileTree,
openTab,
closeTab,
closeAllTabs,
@@ -159,14 +171,11 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
markTabSaved,
setMarkdownViewMode,
setMobileBrowserVisible,
setGitStatusMap,
setExpandedFolders,
setEnhancedGitStatusMap,
setGitBranch,
setActiveFileGitDetails,
activeFileGitDetails,
gitBranch,
enhancedGitStatusMap,
showInlineDiff,
setShowInlineDiff,
activeFileDiff,
} = store;
const activeTab = tabs.find((t) => t.id === activeTabId) || null;
@@ -217,6 +226,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
};
const tree = await buildTree(treePath);
const { setFileTree, setExpandedFolders } = useFileEditorStore.getState();
setFileTree(tree);
if (expandedSnapshot !== null) {
@@ -230,12 +240,14 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
logger.error('Failed to load file tree:', error);
}
},
[effectivePath, setFileTree, setExpandedFolders]
[effectivePath]
);
// ─── Load Git Status ─────────────────────────────────────────
const loadGitStatus = useCallback(async () => {
if (!effectivePath) return;
const { setGitStatusMap, setEnhancedGitStatusMap, setGitBranch } =
useFileEditorStore.getState();
try {
const api = getElectronAPI();
@@ -289,7 +301,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
// Git might not be available - that's okay
logger.debug('Git status not available:', error);
}
}, [effectivePath, setGitStatusMap, setEnhancedGitStatusMap, setGitBranch]);
}, [effectivePath]);
// ─── Load subdirectory children lazily ───────────────────────
const loadSubdirectory = useCallback(async (dirPath: string): Promise<FileTreeNode[]> => {
@@ -448,6 +460,33 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
[handleFileSelect, isMobile, setMobileBrowserVisible]
);
// ─── Load File Diff for Inline Display ───────────────────────────────────
const loadFileDiff = useCallback(
async (filePath: string) => {
if (!effectivePath) return;
const { setActiveFileDiff } = useFileEditorStore.getState();
try {
const api = getElectronAPI();
if (!api.git?.getFileDiff) return;
// Get relative path
const relativePath = filePath.startsWith(effectivePath)
? filePath.substring(effectivePath.length + 1)
: filePath;
const result = await api.git.getFileDiff(effectivePath, relativePath);
if (result.success && result.diff) {
setActiveFileDiff(result.diff);
} else {
setActiveFileDiff(null);
}
} catch {
setActiveFileDiff(null);
}
},
[effectivePath]
);
// ─── Handle Save ─────────────────────────────────────────────
const handleSave = useCallback(async () => {
if (!activeTab || !activeTab.isDirty) return;
@@ -458,15 +497,18 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
if (result.success) {
markTabSaved(activeTab.id, activeTab.content);
// Refresh git status after save
// Refresh git status and inline diff after save
loadGitStatus();
if (showInlineDiff) {
loadFileDiff(activeTab.filePath);
}
} else {
logger.error('Failed to save file:', result.error);
}
} catch (error) {
logger.error('Failed to save file:', error);
}
}, [activeTab, markTabSaved, loadGitStatus]);
}, [activeTab, markTabSaved, loadGitStatus, showInlineDiff, loadFileDiff]);
// ─── Auto Save: save a specific tab by ID ───────────────────
const saveTabById = useCallback(
@@ -482,6 +524,11 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
if (result.success) {
markTabSaved(tab.id, tab.content);
loadGitStatus();
// Refresh inline diff for the saved file if diff is active
const { showInlineDiff, activeTabId: currentActive } = useFileEditorStore.getState();
if (showInlineDiff && tab.id === currentActive) {
loadFileDiff(tab.filePath);
}
} else {
logger.error('Auto-save failed:', result.error);
}
@@ -489,7 +536,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
logger.error('Auto-save failed:', error);
}
},
[markTabSaved, loadGitStatus]
[markTabSaved, loadGitStatus, loadFileDiff]
);
// ─── Auto Save: on tab switch ──────────────────────────────
@@ -560,6 +607,151 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
}
}, []);
// ─── Get current branch from selected worktree ────────────
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
const worktrees = worktreesByProject[currentProject.path] ?? [];
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const selectedWorktree =
currentWorktreePath === null
? worktrees.find((w) => w.isMain)
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
// ─── Create Feature from Selection ─────────────────────────
const handleCreateFeatureFromSelection = useCallback(() => {
if (!activeTab || !editorRef.current || !effectivePath) return;
const selection = editorRef.current.getSelection();
if (!selection) return;
// Compute relative path from effectivePath
const relativePath = activeTab.filePath.startsWith(effectivePath)
? activeTab.filePath.substring(effectivePath.length + 1)
: activeTab.filePath.split('/').pop() || activeTab.filePath;
// Get language extension for code fence
const langName = getLanguageName(activeTab.filePath).toLowerCase();
const langMap: Record<string, string> = {
javascript: 'js',
jsx: 'jsx',
typescript: 'ts',
tsx: 'tsx',
python: 'py',
ruby: 'rb',
shell: 'sh',
'c++': 'cpp',
'plain text': '',
};
const fenceLang = langMap[langName] || langName;
// Truncate selection to ~200 lines
const lines = selection.text.split('\n');
const truncated = lines.length > 200;
const codeText = truncated ? lines.slice(0, 200).join('\n') + '\n[...]' : selection.text;
const description = [
`**File:** \`${relativePath}\` (Lines ${selection.fromLine}-${selection.toLine})`,
'',
`\`\`\`${fenceLang}`,
codeText,
'```',
truncated ? `\n*Selection truncated (${lines.length} lines total)*` : '',
'',
'---',
'',
]
.filter((line) => line !== undefined)
.join('\n');
setFeatureSelectionContext(description);
setShowAddFeatureDialog(true);
}, [activeTab, effectivePath]);
// ─── Handle feature creation from AddFeatureDialog ─────────
const handleAddFeatureFromEditor = useCallback(
async (featureData: {
title: string;
category: string;
description: string;
priority: number;
model: string;
thinkingLevel: string;
reasoningEffort: string;
skipTests: boolean;
branchName: string;
planningMode: string;
requirePlanApproval: boolean;
excludedPipelineSteps?: string[];
workMode: string;
imagePaths?: Array<{ id: string; path: string; description?: string }>;
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
}) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (api.features?.create) {
const feature = {
id: `editor-${generateUUID()}`,
title: featureData.title,
description: featureData.description,
category: featureData.category,
status: 'backlog' as const,
passes: false,
priority: featureData.priority,
model: resolveModelString(featureData.model),
thinkingLevel: featureData.thinkingLevel,
reasoningEffort: featureData.reasoningEffort,
skipTests: featureData.skipTests,
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
planningMode: featureData.planningMode,
requirePlanApproval: featureData.requirePlanApproval,
excludedPipelineSteps: featureData.excludedPipelineSteps,
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
...(featureData.textFilePaths?.length
? { textFilePaths: featureData.textFilePaths }
: {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await api.features.create(currentProject.path, feature as any);
if (result.success) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
toast.success(
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`,
{
action: {
label: 'View Board',
onClick: () => navigate({ to: '/board' }),
},
}
);
setShowAddFeatureDialog(false);
setFeatureSelectionContext(undefined);
} else {
toast.error(result.error || 'Failed to create feature');
}
}
} catch (err) {
logger.error('Create feature from editor error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
}
},
[currentProject?.path, currentBranch, queryClient, navigate]
);
// ─── File Operations ─────────────────────────────────────────
const handleCreateFile = useCallback(
async (parentPath: string, name: string) => {
@@ -847,6 +1039,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const loadFileGitDetails = useCallback(
async (filePath: string) => {
if (!effectivePath) return;
const { setActiveFileGitDetails } = useFileEditorStore.getState();
try {
const api = getElectronAPI();
if (!api.git?.getDetails) return;
@@ -866,7 +1059,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
setActiveFileGitDetails(null);
}
},
[effectivePath, setActiveFileGitDetails]
[effectivePath]
);
// Load git details when active tab changes
@@ -874,9 +1067,24 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
if (activeTab && !activeTab.isBinary) {
loadFileGitDetails(activeTab.filePath);
} else {
setActiveFileGitDetails(null);
useFileEditorStore.getState().setActiveFileGitDetails(null);
}
}, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails, setActiveFileGitDetails]);
}, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails]);
// Load file diff when inline diff is enabled and active tab changes
useEffect(() => {
if (showInlineDiff && activeTab && !activeTab.isBinary && !activeTab.isTooLarge) {
loadFileDiff(activeTab.filePath);
} else {
useFileEditorStore.getState().setActiveFileDiff(null);
}
}, [
showInlineDiff,
activeTab?.filePath,
activeTab?.isBinary,
activeTab?.isTooLarge,
loadFileDiff,
]);
// ─── Handle Cursor Change ────────────────────────────────────
// Stable callback to avoid recreating CodeMirror extensions on every render.
@@ -938,7 +1146,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
return n;
});
};
setFileTree(updateChildren(fileTree));
useFileEditorStore.getState().setFileTree(updateChildren(fileTree));
}
}
@@ -946,7 +1154,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
// on every render, which would make this useCallback's dependency unstable.
useFileEditorStore.getState().toggleFolder(path);
},
[loadSubdirectory, setFileTree]
[loadSubdirectory]
);
// ─── Initial Load ────────────────────────────────────────────
@@ -1088,6 +1296,8 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onCursorChange={handleCursorChange}
onSave={handleSave}
scrollCursorIntoView={isMobile && isKeyboardOpen}
diffContent={showInlineDiff ? activeFileDiff : null}
onSelectionChange={setHasEditorSelection}
/>
</Panel>
<PanelResizeHandle
@@ -1119,6 +1329,8 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onCursorChange={handleCursorChange}
onSave={handleSave}
scrollCursorIntoView={isMobile && isKeyboardOpen}
diffContent={showInlineDiff ? activeFileDiff : null}
onSelectionChange={setHasEditorSelection}
/>
)}
</>
@@ -1302,6 +1514,41 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</Button>
)}
{/* Desktop: Inline Diff toggle */}
{activeTab &&
!activeTab.isBinary &&
!activeTab.isTooLarge &&
!(isMobile && mobileBrowserVisible) && (
<Button
variant={showInlineDiff ? 'default' : 'outline'}
size="sm"
onClick={() => setShowInlineDiff(!showInlineDiff)}
className="hidden lg:flex"
title={showInlineDiff ? 'Hide git diff highlighting' : 'Show git diff highlighting'}
>
<Diff className="w-4 h-4 mr-2" />
Diff
</Button>
)}
{/* Desktop: Create Feature from selection */}
{hasEditorSelection &&
activeTab &&
!activeTab.isBinary &&
!activeTab.isTooLarge &&
!(isMobile && mobileBrowserVisible) && (
<Button
variant="outline"
size="sm"
onClick={handleCreateFeatureFromSelection}
className="hidden lg:flex"
title="Create a board feature from the selected code"
>
<FolderKanban className="w-4 h-4 mr-2" />
Create Feature
</Button>
)}
{/* Editor Settings popover */}
<Popover>
<PopoverTrigger asChild>
@@ -1415,6 +1662,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</Button>
)}
{/* Inline Diff toggle */}
{activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
<button
onClick={() => setShowInlineDiff(!showInlineDiff)}
className={cn(
'flex items-center gap-2 w-full p-2 rounded-lg border transition-colors text-sm',
showInlineDiff
? 'bg-primary/10 border-primary/30 text-primary'
: 'bg-muted/30 border-border text-muted-foreground hover:text-foreground'
)}
>
<Diff className="w-4 h-4" />
<span>{showInlineDiff ? 'Hide Git Diff' : 'Show Git Diff'}</span>
</button>
)}
{/* Create Feature from selection */}
{hasEditorSelection && activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
handleCreateFeatureFromSelection();
setShowActionsPanel(false);
}}
>
<FolderKanban className="w-4 h-4 mr-2" />
Create Feature from Selection
</Button>
)}
{/* File info */}
{activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
<div className="flex flex-col gap-1.5 p-3 rounded-lg bg-muted/30 border border-border">
@@ -1478,6 +1756,27 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</Panel>
</PanelGroup>
)}
{/* Add Feature Dialog - opened from code selection */}
<AddFeatureDialog
open={showAddFeatureDialog}
onOpenChange={(open) => {
setShowAddFeatureDialog(open);
if (!open) {
setFeatureSelectionContext(undefined);
}
}}
onAdd={handleAddFeatureFromEditor}
categorySuggestions={['From Editor']}
branchSuggestions={[]}
defaultSkipTests={defaultSkipTests}
defaultBranch={currentBranch}
currentBranch={currentBranch || undefined}
isMaximized={false}
projectPath={currentProject?.path}
prefilledDescription={featureSelectionContext}
prefilledCategory="From Editor"
/>
</div>
);
}

View File

@@ -101,6 +101,12 @@ interface FileEditorState {
// Git details for the currently active file (loaded on demand)
activeFileGitDetails: GitFileDetailsInfo | null;
// Inline diff display
/** Whether to show inline git diffs in the editor */
showInlineDiff: boolean;
/** The diff content for the active file (raw unified diff) */
activeFileDiff: string | null;
// Drag and drop state
dragState: DragState;
@@ -135,6 +141,9 @@ interface FileEditorState {
setGitBranch: (branch: string) => void;
setActiveFileGitDetails: (details: GitFileDetailsInfo | null) => void;
setShowInlineDiff: (show: boolean) => void;
setActiveFileDiff: (diff: string | null) => void;
setDragState: (state: DragState) => void;
setSelectedPaths: (paths: Set<string>) => void;
toggleSelectedPath: (path: string) => void;
@@ -159,6 +168,8 @@ const initialState = {
enhancedGitStatusMap: new Map<string, EnhancedGitFileStatus>(),
gitBranch: '',
activeFileGitDetails: null as GitFileDetailsInfo | null,
showInlineDiff: false,
activeFileDiff: null as string | null,
dragState: { draggedPaths: [], dropTargetPath: null } as DragState,
selectedPaths: new Set<string>(),
};
@@ -206,8 +217,18 @@ export const useFileEditorStore = create<FileEditorState>()(
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newTab: EditorTab = { ...tabData, id };
let updatedTabs = [...tabs, newTab];
// Enforce max open tabs evict the oldest non-dirty tab when over the limit
const MAX_TABS = 25;
while (updatedTabs.length > MAX_TABS) {
const evictIdx = updatedTabs.findIndex((t) => t.id !== id && !t.isDirty);
if (evictIdx === -1) break; // all other tabs are dirty, keep them
updatedTabs.splice(evictIdx, 1);
}
set({
tabs: [...tabs, newTab],
tabs: updatedTabs,
activeTabId: id,
});
},
@@ -282,6 +303,9 @@ export const useFileEditorStore = create<FileEditorState>()(
setGitBranch: (branch) => set({ gitBranch: branch }),
setActiveFileGitDetails: (details) => set({ activeFileGitDetails: details }),
setShowInlineDiff: (show) => set({ showInlineDiff: show }),
setActiveFileDiff: (diff) => set({ activeFileDiff: diff }),
setDragState: (state) => set({ dragState: state }),
setSelectedPaths: (paths) => set({ selectedPaths: paths }),
toggleSelectedPath: (path) => {

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
@@ -15,6 +16,8 @@ import {
Copy,
Plus,
FolderOpen,
LayoutGrid,
Pin,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -64,6 +67,10 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const copyFiles = copyFilesFromStore ?? EMPTY_FILES;
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Worktree display settings
const pinnedWorktreesCount = useAppStore((s) => s.getPinnedWorktreesCount(project.path));
const setPinnedWorktreesCount = useAppStore((s) => s.setPinnedWorktreesCount);
// Get effective worktrees setting (project override or global fallback)
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
@@ -78,6 +85,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
// Ref for storing previous slider value for rollback on error
const sliderPrevRef = useRef<number | null>(null);
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
@@ -115,6 +125,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
if (response.settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
}
if (response.settings.pinnedWorktreesCount !== undefined) {
setPinnedWorktreesCount(currentPath, response.settings.pinnedWorktreesCount);
}
}
} catch (error) {
if (!isCancelled) {
@@ -135,6 +148,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setPinnedWorktreesCount,
]);
// Load init script content when project changes
@@ -507,6 +521,78 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Display Settings */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Display Settings</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Control how worktrees are presented in the panel. Pinned worktrees appear as tabs, and
remaining worktrees are available in a combined overflow dropdown.
</p>
{/* Pinned Worktrees Count */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="mt-0.5">
<Pin className="w-4 h-4 text-brand-500" />
</div>
<div className="space-y-2 flex-1">
<div className="flex items-center justify-between">
<Label
htmlFor="pinned-worktrees-count"
className="text-foreground cursor-pointer font-medium"
>
Pinned Worktree Tabs
</Label>
<span className="text-sm font-medium text-foreground tabular-nums">
{pinnedWorktreesCount}
</span>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Number of worktree tabs to pin (excluding the main worktree, which is always shown).
</p>
<Slider
id="pinned-worktrees-count"
min={0}
max={25}
step={1}
value={[pinnedWorktreesCount]}
onValueChange={(value) => {
// Capture previous value before mutation for potential rollback
const prevCount = pinnedWorktreesCount;
// Update local state immediately for visual feedback
const newValue = value[0] ?? pinnedWorktreesCount;
setPinnedWorktreesCount(project.path, newValue);
// Store prev for onValueCommit rollback
sliderPrevRef.current = prevCount;
}}
onValueCommit={async (value) => {
const newValue = value[0] ?? pinnedWorktreesCount;
const prev = sliderPrevRef.current ?? pinnedWorktreesCount;
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
pinnedWorktreesCount: newValue,
});
} catch (error) {
console.error('Failed to persist pinnedWorktreesCount:', error);
toast.error('Failed to save pinned worktrees setting');
// Rollback optimistic update using captured previous value
setPinnedWorktreesCount(project.path, prev);
}
}}
className="w-full"
/>
</div>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Copy Files Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">

View File

@@ -906,6 +906,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
if (result.success) {
removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({
featureId,

View File

@@ -15,6 +15,7 @@ interface UseElectronAgentOptions {
model?: string;
thinkingLevel?: string;
onToolUse?: (toolName: string, toolInput: unknown) => void;
onToolResult?: (toolName: string, result: unknown) => void;
}
// Server-side queued prompt type
@@ -72,6 +73,7 @@ export function useElectronAgent({
model,
thinkingLevel,
onToolUse,
onToolResult,
}: UseElectronAgentOptions): UseElectronAgentResult {
const [messages, setMessages] = useState<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
@@ -308,6 +310,12 @@ export function useElectronAgent({
onToolUse?.(event.tool.name, event.tool.input);
break;
case 'tool_result':
// Tool completed - surface result via onToolResult callback
logger.info('Tool result:', event.tool.name);
onToolResult?.(event.tool.name, event.tool.input);
break;
case 'complete':
// Agent finished processing for THIS session
logger.info('Processing complete for session:', sessionId);
@@ -366,7 +374,7 @@ export function useElectronAgent({
unsubscribeRef.current = null;
}
};
}, [sessionId, onToolUse]);
}, [sessionId, onToolUse, onToolResult]);
// Send a message to the agent
const sendMessage = useCallback(

View File

@@ -26,6 +26,10 @@ export function useProjectSettingsLoader() {
(state) => state.setAutoDismissInitScriptIndicator
);
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
const setProjectUseWorktrees = useAppStore((state) => state.setProjectUseWorktrees);
const setPinnedWorktreesCount = useAppStore((state) => state.setPinnedWorktreesCount);
const setWorktreeDropdownThreshold = useAppStore((state) => state.setWorktreeDropdownThreshold);
const setAlwaysUseWorktreeDropdown = useAppStore((state) => state.setAlwaysUseWorktreeDropdown);
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
@@ -100,6 +104,24 @@ export function useProjectSettingsLoader() {
setWorktreeCopyFiles(projectPath, settings.worktreeCopyFiles);
}
// Apply useWorktrees if present
if (settings.useWorktrees !== undefined) {
setProjectUseWorktrees(projectPath, settings.useWorktrees);
}
// Apply worktree display settings if present
if (settings.pinnedWorktreesCount !== undefined) {
setPinnedWorktreesCount(projectPath, settings.pinnedWorktreesCount);
}
if (settings.worktreeDropdownThreshold !== undefined) {
setWorktreeDropdownThreshold(projectPath, settings.worktreeDropdownThreshold);
}
if (settings.alwaysUseWorktreeDropdown !== undefined) {
setAlwaysUseWorktreeDropdown(projectPath, settings.alwaysUseWorktreeDropdown);
}
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
// These are stored directly on the project, so we need to update both
// currentProject AND the projects array to keep them in sync
@@ -167,5 +189,9 @@ export function useProjectSettingsLoader() {
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setProjectUseWorktrees,
setPinnedWorktreesCount,
setWorktreeDropdownThreshold,
setAlwaysUseWorktreeDropdown,
]);
}

View File

@@ -750,6 +750,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
model: 'claude-opus',
thinkingLevel: 'adaptive',
},
muteDoneSound: settings.muteDoneSound ?? false,
disableSplashScreen: settings.disableSplashScreen ?? false,
@@ -759,7 +760,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: { ...DEFAULT_PHASE_MODELS, ...(settings.phaseModels ?? current.phaseModels) },
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefaultModel,
@@ -805,7 +806,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
// (error boundary reloads → restores same bad path → crash again).
// The use-worktrees validation effect will re-discover valid worktrees
// from the server once they load.
currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject),
currentWorktreeByProject: Object.fromEntries(
Object.entries(sanitizeWorktreeByProject(settings.currentWorktreeByProject)).filter(
([, worktree]) => worktree.path === null
)
),
// UI State
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '',

View File

@@ -75,6 +75,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'enhancementModel',
'validationModel',
'phaseModels',
'defaultThinkingLevel',
'defaultReasoningEffort',
'enabledCursorModels',
'cursorDefaultModel',
'enabledOpencodeModels',
@@ -781,9 +783,9 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
defaultFeatureModel: serverSettings.defaultFeatureModel
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
: { model: 'claude-opus' },
: { model: 'claude-opus', thinkingLevel: 'adaptive' },
muteDoneSound: serverSettings.muteDoneSound,
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 1000,
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 10000,
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
@@ -793,6 +795,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
...DEFAULT_PHASE_MODELS,
...(migratedPhaseModels ?? serverSettings.phaseModels),
},
defaultThinkingLevel: serverSettings.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: serverSettings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,

View File

@@ -0,0 +1,155 @@
/**
* Shared CodeMirror language detection utilities.
*
* Extracted from code-editor.tsx so that both the file editor and
* the diff viewer can resolve language extensions from file paths.
*/
import type { Extension } from '@codemirror/state';
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { java } from '@codemirror/lang-java';
import { rust } from '@codemirror/lang-rust';
import { cpp } from '@codemirror/lang-cpp';
import { sql } from '@codemirror/lang-sql';
import { php } from '@codemirror/lang-php';
import { xml } from '@codemirror/lang-xml';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { go } from '@codemirror/legacy-modes/mode/go';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { swift } from '@codemirror/legacy-modes/mode/swift';
/** Detect language extension based on file extension */
export function getLanguageExtension(filePath: string): Extension | null {
const name = filePath.split(/[/\\]/).pop()?.toLowerCase() || '';
const dotIndex = name.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
// Handle files by name first
switch (name) {
case 'dockerfile':
case 'dockerfile.dev':
case 'dockerfile.prod':
return StreamLanguage.define(dockerFile);
case 'makefile':
case 'gnumakefile':
return StreamLanguage.define(shell);
case '.gitignore':
case '.dockerignore':
case '.npmignore':
case '.eslintignore':
return StreamLanguage.define(shell);
case '.env':
case '.env.local':
case '.env.development':
case '.env.production':
return StreamLanguage.define(shell);
}
switch (ext) {
// JavaScript/TypeScript
case 'js':
case 'mjs':
case 'cjs':
return javascript();
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'mts':
case 'cts':
return javascript({ typescript: true });
case 'tsx':
return javascript({ jsx: true, typescript: true });
// Web
case 'html':
case 'htm':
case 'svelte':
case 'vue':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'json':
case 'jsonc':
case 'json5':
return json();
case 'xml':
case 'svg':
case 'xsl':
case 'xslt':
case 'plist':
return xml();
// Markdown
case 'md':
case 'mdx':
case 'markdown':
return markdown();
// Python
case 'py':
case 'pyx':
case 'pyi':
return python();
// Java/Kotlin
case 'java':
case 'kt':
case 'kts':
return java();
// Systems
case 'rs':
return rust();
case 'c':
case 'h':
return cpp();
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
case 'hxx':
return cpp();
case 'go':
return StreamLanguage.define(go);
case 'swift':
return StreamLanguage.define(swift);
// Scripting
case 'rb':
case 'erb':
return StreamLanguage.define(ruby);
case 'php':
return php();
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return StreamLanguage.define(shell);
// Data
case 'sql':
case 'mysql':
case 'pgsql':
return sql();
case 'yaml':
case 'yml':
return StreamLanguage.define(yaml);
case 'toml':
return StreamLanguage.define(toml);
default:
return null; // Plain text fallback
}
}

View File

@@ -131,3 +131,130 @@ export function parseDiff(diffText: string): ParsedFileDiff[] {
return files;
}
/**
* Reconstruct old (original) and new (modified) file content from a single-file
* unified diff string. Used by the CodeMirror merge diff viewer which needs
* both document versions to compute inline highlighting.
*
* For new files (entire content is additions), oldContent will be empty.
* For deleted files (entire content is deletions), newContent will be empty.
*/
export function reconstructFilesFromDiff(diffText: string): {
oldContent: string;
newContent: string;
} {
if (!diffText) return { oldContent: '', newContent: '' };
const lines = diffText.split('\n');
const oldLines: string[] = [];
const newLines: string[] = [];
let inHunk = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip diff header lines
if (
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode') ||
line.startsWith('rename from') ||
line.startsWith('rename to') ||
line.startsWith('similarity index') ||
line.startsWith('old mode') ||
line.startsWith('new mode')
) {
continue;
}
// Hunk header
if (line.startsWith('@@')) {
inHunk = true;
continue;
}
if (!inHunk) continue;
// Skip trailing empty line produced by split('\n')
if (line === '' && i === lines.length - 1) {
continue;
}
// "\ No newline at end of file" marker
if (line.startsWith('\\')) {
continue;
}
if (line.startsWith('+')) {
newLines.push(line.substring(1));
} else if (line.startsWith('-')) {
oldLines.push(line.substring(1));
} else {
// Context line (starts with space or is empty within hunk)
const content = line.startsWith(' ') ? line.substring(1) : line;
oldLines.push(content);
newLines.push(content);
}
}
return {
oldContent: oldLines.join('\n'),
newContent: newLines.join('\n'),
};
}
/**
* Split a combined multi-file diff string into per-file diff strings.
* Each entry in the returned array is a complete diff block for a single file.
*/
export function splitDiffByFile(
combinedDiff: string
): { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] {
if (!combinedDiff) return [];
const results: { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] = [];
const lines = combinedDiff.split('\n');
let currentLines: string[] = [];
let currentFilePath = '';
let currentIsNew = false;
let currentIsDeleted = false;
for (const line of lines) {
if (line.startsWith('diff --git')) {
// Push previous file if exists
if (currentLines.length > 0 && currentFilePath) {
results.push({
filePath: currentFilePath,
diff: currentLines.join('\n'),
isNew: currentIsNew,
isDeleted: currentIsDeleted,
});
}
currentLines = [line];
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFilePath = match ? match[2] : 'unknown';
currentIsNew = false;
currentIsDeleted = false;
} else {
if (line.startsWith('new file mode')) currentIsNew = true;
if (line.startsWith('deleted file mode')) currentIsDeleted = true;
currentLines.push(line);
}
}
// Push last file
if (currentLines.length > 0 && currentFilePath) {
results.push({
filePath: currentFilePath,
diff: currentLines.join('\n'),
isNew: currentIsNew,
isDeleted: currentIsDeleted,
});
}
return results;
}

View File

@@ -2334,6 +2334,23 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
updatePRNumber: async (worktreePath: string, prNumber: number, projectPath?: string) => {
console.log('[Mock] Updating PR number:', { worktreePath, prNumber, projectPath });
return {
success: true,
result: {
branch: 'feature-branch',
prInfo: {
number: prNumber,
url: `https://github.com/example/repo/pull/${prNumber}`,
title: `PR #${prNumber}`,
state: 'OPEN',
createdAt: new Date().toISOString(),
},
},
};
},
getDiffs: async (projectPath: string, featureId: string) => {
console.log('[Mock] Getting file diffs:', { projectPath, featureId });
return {

View File

@@ -2238,6 +2238,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }),
createPR: (worktreePath: string, options?: CreatePROptions) =>
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
updatePRNumber: (worktreePath: string, prNumber: number, projectPath?: string) =>
this.post('/api/worktree/update-pr-number', { worktreePath, prNumber, projectPath }),
getDiffs: (projectPath: string, featureId: string) =>
this.post('/api/worktree/diffs', { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
@@ -2746,6 +2748,9 @@ export class HttpApiClient implements ElectronAPI {
defaultDeleteBranchWithWorktree?: boolean;
autoDismissInitScriptIndicator?: boolean;
worktreeCopyFiles?: string[];
pinnedWorktreesCount?: number;
worktreeDropdownThreshold?: number;
alwaysUseWorktreeDropdown?: boolean;
lastSelectedSessionId?: string;
testCommand?: string;
};

View File

@@ -36,6 +36,7 @@ import {
DEFAULT_COPILOT_MODEL,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_GLOBAL_SETTINGS,
getThinkingLevelsForModel,
} from '@automaker/types';
// Import types from modular type files
@@ -371,9 +372,9 @@ const initialState: AppState = {
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 1000,
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 10000,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
@@ -396,6 +397,10 @@ const initialState: AppState = {
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreeCopyFilesByProject: {},
pinnedWorktreesCountByProject: {},
pinnedWorktreeBranchesByProject: {},
worktreeDropdownThresholdByProject: {},
alwaysUseWorktreeDropdownByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
@@ -2453,7 +2458,20 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }),
setDefaultThinkingLevel: async (level) => {
set({ defaultThinkingLevel: level });
const currentModel = get().defaultFeatureModel;
const modelId = currentModel.model;
const availableLevels = getThinkingLevelsForModel(modelId);
// Also update defaultFeatureModel's thinkingLevel if compatible
if (availableLevels.includes(level)) {
set({
defaultThinkingLevel: level,
defaultFeatureModel: { ...currentModel, thinkingLevel: level },
});
} else {
set({ defaultThinkingLevel: level });
}
// Sync to server
try {
const httpApi = getHttpApiClient();
@@ -2478,7 +2496,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Guard against NaN/Infinity before flooring and clamping
const safeValue = Number.isFinite(maxTurns) ? maxTurns : 1;
// Clamp to valid range
const clamped = Math.max(1, Math.min(2000, Math.floor(safeValue)));
const clamped = Math.max(1, Math.min(10000, Math.floor(safeValue)));
set({ defaultMaxTurns: clamped });
// Sync to server
try {
@@ -2641,6 +2659,65 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})),
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
// Worktree Display Settings actions
setPinnedWorktreesCount: (projectPath, count) =>
set((state) => ({
pinnedWorktreesCountByProject: {
...state.pinnedWorktreesCountByProject,
[projectPath]: count,
},
})),
getPinnedWorktreesCount: (projectPath) => get().pinnedWorktreesCountByProject[projectPath] ?? 0,
setPinnedWorktreeBranches: (projectPath, branches) =>
set((state) => ({
pinnedWorktreeBranchesByProject: {
...state.pinnedWorktreeBranchesByProject,
[projectPath]: branches,
},
})),
getPinnedWorktreeBranches: (projectPath) =>
get().pinnedWorktreeBranchesByProject[projectPath] ?? [],
swapPinnedWorktreeBranch: (projectPath, slotIndex, newBranch) =>
set((state) => {
const src = state.pinnedWorktreeBranchesByProject[projectPath] ?? [];
// Pre-fill up to slotIndex to prevent sparse holes
const current: string[] = Array.from(
{ length: Math.max(src.length, slotIndex + 1) },
(_, i) => src[i] ?? ''
);
// If the new branch is already in another slot, swap them (only when newBranch is non-empty)
const existingIndex = newBranch !== '' ? current.indexOf(newBranch) : -1;
if (existingIndex !== -1 && existingIndex !== slotIndex) {
// Swap: put the old branch from this slot into the other slot
current[existingIndex] = current[slotIndex];
}
current[slotIndex] = newBranch;
return {
pinnedWorktreeBranchesByProject: {
...state.pinnedWorktreeBranchesByProject,
[projectPath]: current,
},
};
}),
setWorktreeDropdownThreshold: (projectPath, threshold) =>
set((state) => ({
worktreeDropdownThresholdByProject: {
...state.worktreeDropdownThresholdByProject,
[projectPath]: threshold,
},
})),
getWorktreeDropdownThreshold: (projectPath) =>
get().worktreeDropdownThresholdByProject[projectPath] ?? 3,
setAlwaysUseWorktreeDropdown: (projectPath, always) =>
set((state) => ({
alwaysUseWorktreeDropdownByProject: {
...state.alwaysUseWorktreeDropdownByProject,
[projectPath]: always,
},
})),
getAlwaysUseWorktreeDropdown: (projectPath) =>
get().alwaysUseWorktreeDropdownByProject[projectPath] ?? true,
// UI State actions
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),

View File

@@ -370,6 +370,17 @@ export interface AppState {
// List of relative file paths to copy from project root into new worktrees
worktreeCopyFilesByProject: Record<string, string[]>;
// Worktree Display Settings (per-project, keyed by project path)
// Number of worktrees always visible (pinned) without expanding a dropdown (default: 1)
pinnedWorktreesCountByProject: Record<string, number>;
// Explicit list of branch names assigned to pinned slots (ordered)
// When set, these branches are shown in the pinned slots instead of using default ordering
pinnedWorktreeBranchesByProject: Record<string, string[]>;
// Minimum number of worktrees before the list collapses into a dropdown (default: 3)
worktreeDropdownThresholdByProject: Record<string, number>;
// Always use dropdown layout regardless of worktree count (default: false)
alwaysUseWorktreeDropdownByProject: Record<string, boolean>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -814,6 +825,17 @@ export interface AppActions {
setWorktreeCopyFiles: (projectPath: string, files: string[]) => void;
getWorktreeCopyFiles: (projectPath: string) => string[];
// Worktree Display Settings actions (per-project)
setPinnedWorktreesCount: (projectPath: string, count: number) => void;
getPinnedWorktreesCount: (projectPath: string) => number;
setPinnedWorktreeBranches: (projectPath: string, branches: string[]) => void;
getPinnedWorktreeBranches: (projectPath: string) => string[];
swapPinnedWorktreeBranch: (projectPath: string, slotIndex: number, newBranch: string) => void;
setWorktreeDropdownThreshold: (projectPath: string, threshold: number) => void;
getWorktreeDropdownThreshold: (projectPath: string) => number;
setAlwaysUseWorktreeDropdown: (projectPath: string, always: boolean) => void;
getAlwaysUseWorktreeDropdown: (projectPath: string) => boolean;
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;

View File

@@ -33,6 +33,14 @@ export interface ToolUse {
input: unknown;
}
export interface ToolResult {
name: string;
input: {
toolUseId?: string;
content: string;
};
}
export type StreamEvent =
| {
type: 'message';
@@ -51,6 +59,11 @@ export type StreamEvent =
sessionId: string;
tool: ToolUse;
}
| {
type: 'tool_result';
sessionId: string;
tool: ToolResult;
}
| {
type: 'complete';
sessionId: string;
@@ -1075,6 +1088,27 @@ export interface WorktreeAPI {
error?: string;
}>;
// Update the tracked PR number for a worktree branch
updatePRNumber: (
worktreePath: string,
prNumber: number,
projectPath?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
prInfo: {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
};
ghCliUnavailable?: boolean;
};
error?: string;
}>;
// Get file diffs for a feature worktree
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;