Skip to content

Commit 0ea1447

Browse files
committed
🤖 Add auto-rebase feature for workspaces
Implement click-to-rebase on commits-behind indicator. Automatically fetches, stashes, rebases onto trunk, and restores changes. Only works when agent is idle. Injects conflict messages into chat when needed. _Generated with cmux_ Change-Id: Ic1ad7698b612f4cb0ae03aad34da17c4c0c41f02 Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Enhance rebase error handling with agent diagnostics When git rebase operations fail (including assertion errors), the system now captures comprehensive diagnostic information and injects it into the agent's chat for resolution. Key improvements: - Catch ALL errors including assertion failures - Track operation step at each stage - Gather git diagnostics (branch, status, rebase state, stash) - Inject detailed error message with: * Operation context (workspace, trunk, step) * Full error message and stack trace * Current git state * Actionable resolution steps - Agent can investigate and resolve issues using bash tool This makes the rebase feature much more resilient - instead of silently failing or leaving the workspace in a bad state, the agent gets full context to diagnose and fix the problem. _Generated with cmux_ Change-Id: I197ce7ebdb4cf7c9e35cd8899bf50c5bf4e401c2 Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Add refresh icon on hover for rebase indicator Show refresh icon (🔄) when hovering over the commits-behind indicator to make it clearer that it's clickable. The ↓N changes to 🔄 on hover when the agent is idle. Also fixed migration timing to run after config is loaded in loadServices(). _Generated with cmux_ Change-Id: I02b5325db4a2110ff1394c9d8117287a76e5eb75 Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Add comprehensive assertions and e2e tests for rebase Defensive programming improvements: - Added type assertions for all function inputs (string checks) - Added output validation assertions for all return paths - Assert result.success matches expected value - Assert result.status matches expected state - Assert required fields are present (error, conflictFiles) E2E test coverage (5 test scenarios): 1. Show behind count when upstream has commits 2. Successfully rebase with no conflicts 3. Stash and restore uncommitted changes 4. Detect and report conflicts with chat injection 5. Fail gracefully when rebase already in progress Each test validates: - Git state before and after operations - Correct ahead/behind counts - File content preservation - Conflict detection and reporting - Error message injection into chat - Proper cleanup of git state This follows the defensive programming guidelines: - Assert all inputs (type, length, existence) - Assert all outputs (success, status, required fields) - Crash fast and loud on invalid state - Comprehensive test coverage for all paths _Generated with cmux_ Fixes the gaps you identified - now we have assertions on EVERYTHING and comprehensive test coverage. Change-Id: Ib5e9451fccb0d146f88176e2d7fe26cbe5c486ad Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Add comprehensive e2e tests for visual rebase feature Added Playwright e2e tests that actually open the app and test the visual UI: Test Coverage (5 scenarios): 1. Behind indicator visibility and click-to-rebase 2. Refresh icon (🔄) appears on hover 3. Stash/restore uncommitted changes during rebase 4. Conflict detection with chat message injection 5. Indicator not clickable while agent streaming What these tests verify visually: - ↓N indicator appears when workspace is behind - Hover changes ↓N to 🔄 (refresh icon) - Cursor changes to pointer when clickable - Clicking performs actual git rebase - Uncommitted files preserved through rebase - Conflict messages appear in chat transcript - Indicator disabled during agent streaming Run with: bun x playwright test tests/e2e/scenarios/gitRebase.spec.ts bun x playwright test tests/e2e/scenarios/gitRebase.spec.ts --headed These are TRUE e2e tests - they test the full user experience, not just backend logic. _Generated with cmux_ This is what you asked for - tests that verify the feature works visually, not just logically. Change-Id: I028246b7c203cf6210f5d87051d080a74a4edf7f Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Fix integration tests - add git remote setup and use setupWorkspaceWithoutProvider Fixed failing integration tests: - Added setupGitRemote() helper to configure workspace with origin remote - Tests now use setupWorkspaceWithoutProvider() instead of setupWorkspace() (rebase tests don't need API calls) - Fixed message content extraction to check parts[0].text - Tests now properly set up git fetch/rebase environment Status: 4/6 tests passing, 2 need minor fixes for conflict handling _Generated with cmux_ Change-Id: I4356a4036a3dc59aa2602c4853d6192e53210ca8 Signed-off-by: Thomas Kosiewski <[email protected]> fix: Replace emoji with SVG refresh icon Change-Id: I61b476b862c714ead11d2e8132bf1b197ef0d032 Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Add pulsating animation when rebasing, keep icon visible After clicking rebase, the refresh icon stays visible and pulsates to show progress. This prevents double-clicks and provides continuous feedback even when not hovering. Changes: - Refresh icon stays visible during rebase (isRebasing state) - Pulsating animation (scale + opacity) runs during rebase - Cursor changes to 'wait' during rebase - StatusIndicators hidden when rebasing - Dirty indicator (*) always visible outside the swap area Visual states: - Normal: ↑2 ↓5 * - Hover: 🔄 * (refresh icon) - Rebasing: 🔄 * (pulsating, cursor:wait) - After: ↑2 ↓0 * (or hidden if caught up) _Generated with cmux_ Change-Id: If70a5d5d549b4db4754fc0beabd58b37dc0ca50a Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Remove browser title tooltip to prevent multiple popups The custom tooltip (showing git history) is sufficient. The browser's native title tooltip was creating a duplicate grey popup in the background. _Generated with cmux_ Change-Id: Ib2d0b18a08e13d1b417d32f1f59d41323273fff2 Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Fix refresh icon persistence during rebase The refresh icon now stays visible during the entire rebase operation, even when hovering away. Added !props.isRebasing condition to hover logic so rebasing state overrides hover state. _Generated with cmux_ Change-Id: I3eb7d346b4c153a0494d34aff5b70d1dcdc34cd8 Signed-off-by: Thomas Kosiewski <[email protected]> 🤖 Use git rebase --autostash instead of manual stash/pop Simplified rebase logic by using Git's built-in --autostash flag instead of manually stashing and popping. This is cleaner, handles edge cases better, and reduces code complexity. Changes: - Removed manual stash push/pop logic - Added --autostash flag to git rebase command - Updated all documentation to reflect new approach - Marked stashed field as deprecated (always false now) - Removed ~30 lines of manual stash handling code Benefits: - Git handles stash lifecycle automatically - No more 'stash pop failed' edge cases - Cleaner, simpler implementation - Follows Git best practices _Generated with cmux_ Change-Id: Idde06d4a68c0a2f0d27133e3a3d16d8051fdd239 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 34e01b9 commit 0ea1447

22 files changed

+2559
-389
lines changed

src/assets/icons/refresh.svg

Lines changed: 3 additions & 0 deletions
Loading

src/components/AIView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
438438
gitStatus={gitStatus}
439439
workspaceId={workspaceId}
440440
tooltipPosition="bottom"
441+
isStreaming={canInterrupt}
441442
/>
442443
{projectName} / {branch}
443444
<WorkspacePath>{namedWorkspacePath}</WorkspacePath>
Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
1-
import React, { useState, useRef, useEffect } from "react";
1+
import React, { useCallback, useEffect, useRef, useState } from "react";
22
import type { GitStatus } from "@/types/workspace";
33
import { GitStatusIndicatorView } from "./GitStatusIndicatorView";
44
import { useGitBranchDetails } from "./hooks/useGitBranchDetails";
5+
import { strict as assert } from "node:assert";
56

67
interface GitStatusIndicatorProps {
78
gitStatus: GitStatus | null;
89
workspaceId: string;
910
tooltipPosition?: "right" | "bottom";
11+
isStreaming?: boolean;
1012
}
1113

1214
/**
1315
* Container component for git status indicator.
14-
* Manages tooltip visibility, positioning, and data fetching.
16+
* Manages tooltip visibility, positioning, data fetching, and auto-rebase UX.
1517
* Delegates rendering to GitStatusIndicatorView.
1618
*/
1719
export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
1820
gitStatus,
1921
workspaceId,
2022
tooltipPosition = "right",
23+
isStreaming = false,
2124
}) => {
2225
const [showTooltip, setShowTooltip] = useState(false);
2326
const [tooltipCoords, setTooltipCoords] = useState<{ top: number; left: number }>({
@@ -26,41 +29,38 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
2629
});
2730
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
2831
const containerRef = useRef<HTMLSpanElement | null>(null);
29-
const trimmedWorkspaceId = workspaceId.trim();
32+
const [isRebasing, setIsRebasing] = useState(false);
33+
const [rebaseError, setRebaseError] = useState<string | null>(null);
3034

31-
console.assert(
35+
const trimmedWorkspaceId = workspaceId.trim();
36+
assert(
3237
trimmedWorkspaceId.length > 0,
3338
"GitStatusIndicator requires workspaceId to be a non-empty string."
3439
);
3540

36-
// Fetch branch details only when tooltip should be shown
37-
const { branchHeaders, commits, dirtyFiles, isLoading, errorMessage } = useGitBranchDetails(
38-
trimmedWorkspaceId,
39-
gitStatus,
40-
showTooltip
41-
);
41+
const { branchHeaders, commits, dirtyFiles, isLoading, errorMessage, invalidateCache, refresh } =
42+
useGitBranchDetails(trimmedWorkspaceId, gitStatus, showTooltip);
4243

43-
const handleMouseEnter = () => {
44-
// Cancel any pending hide timeout
44+
const cancelHideTimeout = () => {
4545
if (hideTimeoutRef.current) {
4646
clearTimeout(hideTimeoutRef.current);
4747
hideTimeoutRef.current = null;
4848
}
49+
};
4950

51+
const handleMouseEnter = () => {
52+
cancelHideTimeout();
5053
setShowTooltip(true);
5154

52-
// Calculate tooltip position based on indicator position
5355
if (containerRef.current) {
5456
const rect = containerRef.current.getBoundingClientRect();
5557

5658
if (tooltipPosition === "right") {
57-
// Position to the right of the indicator
5859
setTooltipCoords({
5960
top: rect.top + rect.height / 2,
6061
left: rect.right + 8,
6162
});
6263
} else {
63-
// Position below the indicator
6464
setTooltipCoords({
6565
top: rect.bottom + 8,
6666
left: rect.left,
@@ -70,38 +70,96 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
7070
};
7171

7272
const handleMouseLeave = () => {
73-
// Delay hiding to give user time to move cursor to tooltip
7473
hideTimeoutRef.current = setTimeout(() => {
7574
setShowTooltip(false);
7675
}, 300);
7776
};
7877

7978
const handleTooltipMouseEnter = () => {
80-
// Cancel hide timeout when hovering tooltip
81-
if (hideTimeoutRef.current) {
82-
clearTimeout(hideTimeoutRef.current);
83-
hideTimeoutRef.current = null;
84-
}
79+
cancelHideTimeout();
8580
};
8681

8782
const handleTooltipMouseLeave = () => {
88-
// Hide immediately when leaving tooltip
8983
setShowTooltip(false);
9084
};
9185

9286
const handleContainerRef = (el: HTMLSpanElement | null) => {
9387
containerRef.current = el;
9488
};
9589

96-
// Cleanup timeout on unmount
90+
const canRebase = !!gitStatus && gitStatus.behind > 0 && !isStreaming && !isRebasing;
91+
92+
const handleRebaseClick = useCallback(async () => {
93+
if (!gitStatus || gitStatus.behind <= 0 || isStreaming || isRebasing) {
94+
return;
95+
}
96+
97+
setIsRebasing(true);
98+
setRebaseError(null);
99+
100+
try {
101+
const result = await window.api?.workspace?.rebase?.(trimmedWorkspaceId);
102+
103+
assert(
104+
typeof result !== "undefined",
105+
"workspace.rebase IPC handler must exist before attempting auto-rebase."
106+
);
107+
108+
if (!result) {
109+
setRebaseError("Auto-rebase unavailable: workspace IPC handler missing.");
110+
return;
111+
}
112+
113+
if (result.success) {
114+
invalidateCache();
115+
if (showTooltip) {
116+
refresh();
117+
}
118+
return;
119+
}
120+
121+
invalidateCache();
122+
123+
if (result.status === "conflicts") {
124+
setRebaseError(
125+
result.error ??
126+
"Rebase hit conflicts. Check the chat for details and resolve before continuing."
127+
);
128+
} else {
129+
setRebaseError(result.error ?? "Rebase failed unexpectedly.");
130+
}
131+
} catch (error) {
132+
const message = error instanceof Error ? error.message : String(error);
133+
setRebaseError(`Failed to rebase: ${message}`);
134+
} finally {
135+
setIsRebasing(false);
136+
}
137+
}, [
138+
gitStatus,
139+
invalidateCache,
140+
isRebasing,
141+
isStreaming,
142+
refresh,
143+
showTooltip,
144+
trimmedWorkspaceId,
145+
]);
146+
147+
const triggerRebase = useCallback(() => {
148+
void handleRebaseClick();
149+
}, [handleRebaseClick]);
150+
97151
useEffect(() => {
98152
return () => {
99-
if (hideTimeoutRef.current) {
100-
clearTimeout(hideTimeoutRef.current);
101-
}
153+
cancelHideTimeout();
102154
};
103155
}, []);
104156

157+
useEffect(() => {
158+
if (gitStatus?.behind === 0) {
159+
setRebaseError(null);
160+
}
161+
}, [gitStatus]);
162+
105163
return (
106164
<GitStatusIndicatorView
107165
gitStatus={gitStatus}
@@ -118,6 +176,10 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
118176
onTooltipMouseEnter={handleTooltipMouseEnter}
119177
onTooltipMouseLeave={handleTooltipMouseLeave}
120178
onContainerRef={handleContainerRef}
179+
canRebase={canRebase}
180+
isRebasing={isRebasing}
181+
onRebaseClick={triggerRebase}
182+
rebaseError={rebaseError}
121183
/>
122184
);
123185
};

0 commit comments

Comments
 (0)