Skip to content

Commit e6e4c2e

Browse files
authored
🤖 feat: line-delta git status indicator with toggle (#1108)
Changes the GitStatusIndicator to show **line deltas** (additions/deletions) by default instead of commit divergence (ahead/behind). Users can toggle between views via the tooltip. ## Summary - **Default view is now line-delta**: shows `+{additions} -{deletions}` with GitHub-style coloring (green/red when working, muted when idle) - **Tooltip toggle**: click `Lines` / `Commits` segmented control to switch views globally (persisted to localStorage) - **Tooltip overview**: always shows both line delta and commit divergence summary - **Large number abbreviation**: 999 → 999, 1231 → 1.2k, 12313 → 12.3k - **New ToggleGroup UI component**: reusable shadcn-style segmented control ## Implementation - Extended `GitStatus` type with `outgoingAdditions`, `outgoingDeletions`, `incomingAdditions`, `incomingDeletions` - Updated git status script to compute line deltas via `git diff --numstat` against merge-base - Added `GIT_STATUS_INDICATOR_MODE_KEY` for persisted global preference - Added Storybook stories for both modes in busy (streaming) scenarios _Generated with `mux`_
1 parent df06795 commit e6e4c2e

File tree

14 files changed

+591
-52
lines changed

14 files changed

+591
-52
lines changed

docs/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.
7171

7272
### Storybook
7373

74-
- Prefer full-app stories (`App.stories.tsx`) to isolated component stories. This tests components in their real context with proper providers, state management, and styling.
74+
- **Only** add full-app stories (`App.*.stories.tsx`). Do not add isolated component stories, even for small UI changes (they are not used/accepted in this repo).
7575
- Use play functions with `@storybook/test` utilities (`within`, `userEvent`, `waitFor`) to interact with the UI and set up the desired visual state. Do not add props to production components solely for storybook convenience.
7676
- Keep story data deterministic: avoid `Math.random()`, `Date.now()`, or other non-deterministic values in story setup. Pass explicit values when ordering or timing matters for visual stability.
7777

src/browser/components/GitStatusIndicator.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import React, { useState, useRef, useEffect } from "react";
1+
import React, { useState, useRef, useEffect, useCallback } from "react";
22
import type { GitStatus } from "@/common/types/workspace";
3-
import { GitStatusIndicatorView } from "./GitStatusIndicatorView";
3+
import { GIT_STATUS_INDICATOR_MODE_KEY } from "@/common/constants/storage";
4+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
5+
import { GitStatusIndicatorView, type GitStatusIndicatorMode } from "./GitStatusIndicatorView";
46
import { useGitBranchDetails } from "./hooks/useGitBranchDetails";
57

68
interface GitStatusIndicatorProps {
@@ -31,6 +33,19 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
3133
const containerRef = useRef<HTMLSpanElement | null>(null);
3234
const trimmedWorkspaceId = workspaceId.trim();
3335

36+
const [mode, setMode] = usePersistedState<GitStatusIndicatorMode>(
37+
GIT_STATUS_INDICATOR_MODE_KEY,
38+
"line-delta",
39+
{ listener: true }
40+
);
41+
42+
const handleModeChange = useCallback(
43+
(nextMode: GitStatusIndicatorMode) => {
44+
setMode(nextMode);
45+
},
46+
[setMode]
47+
);
48+
3449
console.assert(
3550
trimmedWorkspaceId.length > 0,
3651
"GitStatusIndicator requires workspaceId to be a non-empty string."
@@ -107,6 +122,7 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
107122

108123
return (
109124
<GitStatusIndicatorView
125+
mode={mode}
110126
gitStatus={gitStatus}
111127
tooltipPosition={tooltipPosition}
112128
branchHeaders={branchHeaders}
@@ -119,6 +135,7 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
119135
onMouseEnter={handleMouseEnter}
120136
onMouseLeave={handleMouseLeave}
121137
onTooltipMouseEnter={handleTooltipMouseEnter}
138+
onModeChange={handleModeChange}
122139
onTooltipMouseLeave={handleTooltipMouseLeave}
123140
onContainerRef={handleContainerRef}
124141
isWorking={isWorking}

src/browser/components/GitStatusIndicatorView.tsx

Lines changed: 133 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
33
import type { GitStatus } from "@/common/types/workspace";
44
import type { GitCommit, GitBranchHeader } from "@/common/utils/git/parseGitLog";
55
import { cn } from "@/common/lib/utils";
6+
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
67

78
// Helper for indicator colors
89
const getIndicatorColor = (branch: number): string => {
@@ -18,9 +19,30 @@ const getIndicatorColor = (branch: number): string => {
1819
}
1920
};
2021

22+
function formatCountAbbrev(count: number): string {
23+
const abs = Math.abs(count);
24+
25+
if (abs < 1000) {
26+
return String(count);
27+
}
28+
29+
if (abs < 1_000_000) {
30+
const raw = (abs / 1000).toFixed(1);
31+
const normalized = raw.endsWith(".0") ? raw.slice(0, -2) : raw;
32+
return `${count < 0 ? "-" : ""}${normalized}k`;
33+
}
34+
35+
const raw = (abs / 1_000_000).toFixed(1);
36+
const normalized = raw.endsWith(".0") ? raw.slice(0, -2) : raw;
37+
return `${count < 0 ? "-" : ""}${normalized}m`;
38+
}
39+
40+
export type GitStatusIndicatorMode = "divergence" | "line-delta";
41+
2142
export interface GitStatusIndicatorViewProps {
2243
gitStatus: GitStatus | null;
2344
tooltipPosition?: "right" | "bottom";
45+
mode: GitStatusIndicatorMode;
2446
// Tooltip data
2547
branchHeaders: GitBranchHeader[] | null;
2648
commits: GitCommit[] | null;
@@ -34,6 +56,7 @@ export interface GitStatusIndicatorViewProps {
3456
onMouseLeave: () => void;
3557
onTooltipMouseEnter: () => void;
3658
onTooltipMouseLeave: () => void;
59+
onModeChange: (nextMode: GitStatusIndicatorMode) => void;
3760
onContainerRef: (el: HTMLSpanElement | null) => void;
3861
/** When true, shows blue pulsing styling to indicate agent is working */
3962
isWorking?: boolean;
@@ -47,6 +70,7 @@ export interface GitStatusIndicatorViewProps {
4770
export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
4871
gitStatus,
4972
tooltipPosition = "right",
73+
mode,
5074
branchHeaders,
5175
commits,
5276
dirtyFiles,
@@ -58,6 +82,7 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
5882
onMouseLeave,
5983
onTooltipMouseEnter,
6084
onTooltipMouseLeave,
85+
onModeChange,
6186
onContainerRef,
6287
isWorking = false,
6388
}) => {
@@ -71,8 +96,16 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
7196
);
7297
}
7398

99+
const outgoingLines = gitStatus.outgoingAdditions + gitStatus.outgoingDeletions;
100+
74101
// Render empty placeholder when nothing to show (prevents layout shift)
75-
if (gitStatus.ahead === 0 && gitStatus.behind === 0 && !gitStatus.dirty) {
102+
// In line-delta mode, also show if behind so users can toggle to divergence view
103+
const isEmpty =
104+
mode === "divergence"
105+
? gitStatus.ahead === 0 && gitStatus.behind === 0 && !gitStatus.dirty
106+
: outgoingLines === 0 && !gitStatus.dirty && gitStatus.behind === 0;
107+
108+
if (isEmpty) {
76109
return (
77110
<span
78111
className="text-accent relative mr-1.5 flex items-center gap-1 font-mono text-[11px]"
@@ -186,6 +219,16 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
186219
);
187220
};
188221

222+
const outgoingHasDelta = gitStatus.outgoingAdditions > 0 || gitStatus.outgoingDeletions > 0;
223+
const hasCommitDivergence = gitStatus.ahead > 0 || gitStatus.behind > 0;
224+
225+
// Dynamic color based on working state
226+
// Idle: muted/grayscale, Working: original accent colors
227+
const statusColor = isWorking ? "text-accent" : "text-muted";
228+
const dirtyColor = isWorking ? "text-git-dirty" : "text-muted";
229+
const additionsColor = isWorking ? "text-success-light" : "text-muted";
230+
const deletionsColor = isWorking ? "text-warning-light" : "text-muted";
231+
189232
// Render tooltip via portal to bypass overflow constraints
190233
const tooltipElement = (
191234
<div
@@ -201,15 +244,61 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
201244
onMouseEnter={onTooltipMouseEnter}
202245
onMouseLeave={onTooltipMouseLeave}
203246
>
247+
<div className="border-separator-light mb-2 flex flex-col gap-1 border-b pb-2">
248+
<div className="flex items-center gap-2">
249+
<span className="text-muted-light">Divergence:</span>
250+
<ToggleGroup
251+
type="single"
252+
value={mode}
253+
onValueChange={(value) => {
254+
if (!value) return;
255+
onModeChange(value as GitStatusIndicatorMode);
256+
}}
257+
aria-label="Git status indicator mode"
258+
size="sm"
259+
>
260+
<ToggleGroupItem value="line-delta" aria-label="Show line delta" size="sm">
261+
Lines
262+
</ToggleGroupItem>
263+
<ToggleGroupItem value="divergence" aria-label="Show commit divergence" size="sm">
264+
Commits
265+
</ToggleGroupItem>
266+
</ToggleGroup>
267+
</div>
268+
269+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
270+
<span className="text-muted-light">Overview:</span>
271+
{outgoingHasDelta ? (
272+
<span className="flex items-center gap-2">
273+
{gitStatus.outgoingAdditions > 0 && (
274+
<span className={cn("font-normal", additionsColor)}>
275+
+{formatCountAbbrev(gitStatus.outgoingAdditions)}
276+
</span>
277+
)}
278+
{gitStatus.outgoingDeletions > 0 && (
279+
<span className={cn("font-normal", deletionsColor)}>
280+
-{formatCountAbbrev(gitStatus.outgoingDeletions)}
281+
</span>
282+
)}
283+
</span>
284+
) : (
285+
<span className="text-muted">Lines: 0</span>
286+
)}
287+
{hasCommitDivergence ? (
288+
<span className="text-muted">
289+
Commits: {formatCountAbbrev(gitStatus.ahead)} ahead ·{" "}
290+
{formatCountAbbrev(gitStatus.behind)} behind
291+
</span>
292+
) : (
293+
<span className="text-muted">Commits: 0</span>
294+
)}
295+
</div>
296+
</div>
297+
204298
{renderTooltipContent()}
205299
</div>
206300
);
207301

208-
// Dynamic color based on working state
209-
// Idle: muted/grayscale, Working: original accent colors
210-
const statusColor = isWorking ? "text-accent" : "text-muted";
211-
const dirtyColor = isWorking ? "text-git-dirty" : "text-muted";
212-
213302
return (
214303
<>
215304
<span
@@ -221,11 +310,44 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
221310
statusColor
222311
)}
223312
>
224-
{gitStatus.ahead > 0 && (
225-
<span className="flex items-center font-normal">{gitStatus.ahead}</span>
226-
)}
227-
{gitStatus.behind > 0 && (
228-
<span className="flex items-center font-normal">{gitStatus.behind}</span>
313+
{mode === "divergence" ? (
314+
<>
315+
{gitStatus.ahead > 0 && (
316+
<span className="flex items-center font-normal">
317+
{formatCountAbbrev(gitStatus.ahead)}
318+
</span>
319+
)}
320+
{gitStatus.behind > 0 && (
321+
<span className="flex items-center font-normal">
322+
{formatCountAbbrev(gitStatus.behind)}
323+
</span>
324+
)}
325+
</>
326+
) : (
327+
<>
328+
{outgoingHasDelta ? (
329+
<span className="flex items-center gap-2">
330+
{gitStatus.outgoingAdditions > 0 && (
331+
<span className={cn("font-normal", additionsColor)}>
332+
+{formatCountAbbrev(gitStatus.outgoingAdditions)}
333+
</span>
334+
)}
335+
{gitStatus.outgoingDeletions > 0 && (
336+
<span className={cn("font-normal", deletionsColor)}>
337+
-{formatCountAbbrev(gitStatus.outgoingDeletions)}
338+
</span>
339+
)}
340+
</span>
341+
) : (
342+
// No outgoing lines but behind remote - show muted behind indicator
343+
// so users know they can hover to toggle to divergence view
344+
gitStatus.behind > 0 && (
345+
<span className="text-muted flex items-center font-normal">
346+
{formatCountAbbrev(gitStatus.behind)}
347+
</span>
348+
)
349+
)}
350+
</>
229351
)}
230352
{gitStatus.dirty && (
231353
<span className={cn("flex items-center leading-none font-normal", dirtyColor)}>*</span>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
2+
import * as React from "react";
3+
import { cva, type VariantProps } from "class-variance-authority";
4+
5+
import { cn } from "@/common/lib/utils";
6+
7+
const toggleGroupVariants = cva("inline-flex items-center justify-center rounded-md bg-hover p-1", {
8+
variants: {
9+
variant: {
10+
default: "",
11+
},
12+
size: {
13+
default: "h-7",
14+
sm: "h-6",
15+
},
16+
},
17+
defaultVariants: {
18+
variant: "default",
19+
size: "default",
20+
},
21+
});
22+
23+
const toggleGroupItemVariants = cva(
24+
"inline-flex items-center justify-center whitespace-nowrap rounded-[6px] px-2 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50",
25+
{
26+
variants: {
27+
variant: {
28+
default:
29+
"text-muted data-[state=on]:bg-border-medium data-[state=on]:text-foreground hover:text-foreground",
30+
},
31+
size: {
32+
default: "h-5",
33+
sm: "h-4",
34+
},
35+
},
36+
defaultVariants: {
37+
variant: "default",
38+
size: "default",
39+
},
40+
}
41+
);
42+
43+
const ToggleGroup = React.forwardRef<
44+
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
45+
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
46+
VariantProps<typeof toggleGroupVariants>
47+
>(({ className, variant, size, ...props }, ref) => (
48+
<ToggleGroupPrimitive.Root
49+
ref={ref}
50+
className={cn(toggleGroupVariants({ variant, size, className }))}
51+
{...props}
52+
/>
53+
));
54+
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
55+
56+
const ToggleGroupItem = React.forwardRef<
57+
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
58+
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
59+
VariantProps<typeof toggleGroupItemVariants>
60+
>(({ className, variant, size, ...props }, ref) => (
61+
<ToggleGroupPrimitive.Item
62+
ref={ref}
63+
className={cn(toggleGroupItemVariants({ variant, size, className }))}
64+
{...props}
65+
/>
66+
));
67+
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
68+
69+
export { ToggleGroup, ToggleGroupItem };

0 commit comments

Comments
 (0)