Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.

### Storybook

- 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.
- **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).
- 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.
- 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.

Expand Down
21 changes: 19 additions & 2 deletions src/browser/components/GitStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, useCallback } from "react";
import type { GitStatus } from "@/common/types/workspace";
import { GitStatusIndicatorView } from "./GitStatusIndicatorView";
import { GIT_STATUS_INDICATOR_MODE_KEY } from "@/common/constants/storage";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { GitStatusIndicatorView, type GitStatusIndicatorMode } from "./GitStatusIndicatorView";
import { useGitBranchDetails } from "./hooks/useGitBranchDetails";

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

const [mode, setMode] = usePersistedState<GitStatusIndicatorMode>(
GIT_STATUS_INDICATOR_MODE_KEY,
"line-delta",
{ listener: true }
);

const handleModeChange = useCallback(
(nextMode: GitStatusIndicatorMode) => {
setMode(nextMode);
},
[setMode]
);

console.assert(
trimmedWorkspaceId.length > 0,
"GitStatusIndicator requires workspaceId to be a non-empty string."
Expand Down Expand Up @@ -107,6 +122,7 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({

return (
<GitStatusIndicatorView
mode={mode}
gitStatus={gitStatus}
tooltipPosition={tooltipPosition}
branchHeaders={branchHeaders}
Expand All @@ -119,6 +135,7 @@ export const GitStatusIndicator: React.FC<GitStatusIndicatorProps> = ({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTooltipMouseEnter={handleTooltipMouseEnter}
onModeChange={handleModeChange}
onTooltipMouseLeave={handleTooltipMouseLeave}
onContainerRef={handleContainerRef}
isWorking={isWorking}
Expand Down
144 changes: 133 additions & 11 deletions src/browser/components/GitStatusIndicatorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import type { GitStatus } from "@/common/types/workspace";
import type { GitCommit, GitBranchHeader } from "@/common/utils/git/parseGitLog";
import { cn } from "@/common/lib/utils";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";

// Helper for indicator colors
const getIndicatorColor = (branch: number): string => {
Expand All @@ -18,9 +19,30 @@ const getIndicatorColor = (branch: number): string => {
}
};

function formatCountAbbrev(count: number): string {
const abs = Math.abs(count);

if (abs < 1000) {
return String(count);
}

if (abs < 1_000_000) {
const raw = (abs / 1000).toFixed(1);
const normalized = raw.endsWith(".0") ? raw.slice(0, -2) : raw;
return `${count < 0 ? "-" : ""}${normalized}k`;
}

const raw = (abs / 1_000_000).toFixed(1);
const normalized = raw.endsWith(".0") ? raw.slice(0, -2) : raw;
return `${count < 0 ? "-" : ""}${normalized}m`;
}

export type GitStatusIndicatorMode = "divergence" | "line-delta";

export interface GitStatusIndicatorViewProps {
gitStatus: GitStatus | null;
tooltipPosition?: "right" | "bottom";
mode: GitStatusIndicatorMode;
// Tooltip data
branchHeaders: GitBranchHeader[] | null;
commits: GitCommit[] | null;
Expand All @@ -34,6 +56,7 @@ export interface GitStatusIndicatorViewProps {
onMouseLeave: () => void;
onTooltipMouseEnter: () => void;
onTooltipMouseLeave: () => void;
onModeChange: (nextMode: GitStatusIndicatorMode) => void;
onContainerRef: (el: HTMLSpanElement | null) => void;
/** When true, shows blue pulsing styling to indicate agent is working */
isWorking?: boolean;
Expand All @@ -47,6 +70,7 @@ export interface GitStatusIndicatorViewProps {
export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
gitStatus,
tooltipPosition = "right",
mode,
branchHeaders,
commits,
dirtyFiles,
Expand All @@ -58,6 +82,7 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
onMouseLeave,
onTooltipMouseEnter,
onTooltipMouseLeave,
onModeChange,
onContainerRef,
isWorking = false,
}) => {
Expand All @@ -71,8 +96,16 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
);
}

const outgoingLines = gitStatus.outgoingAdditions + gitStatus.outgoingDeletions;

// Render empty placeholder when nothing to show (prevents layout shift)
if (gitStatus.ahead === 0 && gitStatus.behind === 0 && !gitStatus.dirty) {
// In line-delta mode, also show if behind so users can toggle to divergence view
const isEmpty =
mode === "divergence"
? gitStatus.ahead === 0 && gitStatus.behind === 0 && !gitStatus.dirty
: outgoingLines === 0 && !gitStatus.dirty && gitStatus.behind === 0;

if (isEmpty) {
return (
<span
className="text-accent relative mr-1.5 flex items-center gap-1 font-mono text-[11px]"
Expand Down Expand Up @@ -186,6 +219,16 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
);
};

const outgoingHasDelta = gitStatus.outgoingAdditions > 0 || gitStatus.outgoingDeletions > 0;
const hasCommitDivergence = gitStatus.ahead > 0 || gitStatus.behind > 0;

// Dynamic color based on working state
// Idle: muted/grayscale, Working: original accent colors
const statusColor = isWorking ? "text-accent" : "text-muted";
const dirtyColor = isWorking ? "text-git-dirty" : "text-muted";
const additionsColor = isWorking ? "text-success-light" : "text-muted";
const deletionsColor = isWorking ? "text-warning-light" : "text-muted";

// Render tooltip via portal to bypass overflow constraints
const tooltipElement = (
<div
Expand All @@ -201,15 +244,61 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
onMouseEnter={onTooltipMouseEnter}
onMouseLeave={onTooltipMouseLeave}
>
<div className="border-separator-light mb-2 flex flex-col gap-1 border-b pb-2">
<div className="flex items-center gap-2">
<span className="text-muted-light">Divergence:</span>
<ToggleGroup
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
onModeChange(value as GitStatusIndicatorMode);
}}
aria-label="Git status indicator mode"
size="sm"
>
<ToggleGroupItem value="line-delta" aria-label="Show line delta" size="sm">
Lines
</ToggleGroupItem>
<ToggleGroupItem value="divergence" aria-label="Show commit divergence" size="sm">
Commits
</ToggleGroupItem>
</ToggleGroup>
</div>

<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<span className="text-muted-light">Overview:</span>
{outgoingHasDelta ? (
<span className="flex items-center gap-2">
{gitStatus.outgoingAdditions > 0 && (
<span className={cn("font-normal", additionsColor)}>
+{formatCountAbbrev(gitStatus.outgoingAdditions)}
</span>
)}
{gitStatus.outgoingDeletions > 0 && (
<span className={cn("font-normal", deletionsColor)}>
-{formatCountAbbrev(gitStatus.outgoingDeletions)}
</span>
)}
</span>
) : (
<span className="text-muted">Lines: 0</span>
)}
{hasCommitDivergence ? (
<span className="text-muted">
Commits: {formatCountAbbrev(gitStatus.ahead)} ahead ·{" "}
{formatCountAbbrev(gitStatus.behind)} behind
</span>
) : (
<span className="text-muted">Commits: 0</span>
)}
</div>
</div>

{renderTooltipContent()}
</div>
);

// Dynamic color based on working state
// Idle: muted/grayscale, Working: original accent colors
const statusColor = isWorking ? "text-accent" : "text-muted";
const dirtyColor = isWorking ? "text-git-dirty" : "text-muted";

return (
<>
<span
Expand All @@ -221,11 +310,44 @@ export const GitStatusIndicatorView: React.FC<GitStatusIndicatorViewProps> = ({
statusColor
)}
>
{gitStatus.ahead > 0 && (
<span className="flex items-center font-normal">↑{gitStatus.ahead}</span>
)}
{gitStatus.behind > 0 && (
<span className="flex items-center font-normal">↓{gitStatus.behind}</span>
{mode === "divergence" ? (
<>
{gitStatus.ahead > 0 && (
<span className="flex items-center font-normal">
↑{formatCountAbbrev(gitStatus.ahead)}
</span>
)}
{gitStatus.behind > 0 && (
<span className="flex items-center font-normal">
↓{formatCountAbbrev(gitStatus.behind)}
</span>
)}
</>
) : (
<>
{outgoingHasDelta ? (
<span className="flex items-center gap-2">
{gitStatus.outgoingAdditions > 0 && (
<span className={cn("font-normal", additionsColor)}>
+{formatCountAbbrev(gitStatus.outgoingAdditions)}
</span>
)}
{gitStatus.outgoingDeletions > 0 && (
<span className={cn("font-normal", deletionsColor)}>
-{formatCountAbbrev(gitStatus.outgoingDeletions)}
</span>
)}
</span>
) : (
// No outgoing lines but behind remote - show muted behind indicator
// so users know they can hover to toggle to divergence view
gitStatus.behind > 0 && (
<span className="text-muted flex items-center font-normal">
↓{formatCountAbbrev(gitStatus.behind)}
</span>
)
)}
</>
)}
{gitStatus.dirty && (
<span className={cn("flex items-center leading-none font-normal", dirtyColor)}>*</span>
Expand Down
69 changes: 69 additions & 0 deletions src/browser/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/common/lib/utils";

const toggleGroupVariants = cva("inline-flex items-center justify-center rounded-md bg-hover p-1", {
variants: {
variant: {
default: "",
},
size: {
default: "h-7",
sm: "h-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});

const toggleGroupItemVariants = cva(
"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",
{
variants: {
variant: {
default:
"text-muted data-[state=on]:bg-border-medium data-[state=on]:text-foreground hover:text-foreground",
},
size: {
default: "h-5",
sm: "h-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);

const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupVariants>
>(({ className, variant, size, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(toggleGroupVariants({ variant, size, className }))}
{...props}
/>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;

const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, size, ...props }, ref) => (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleGroupItemVariants({ variant, size, className }))}
{...props}
/>
));
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;

export { ToggleGroup, ToggleGroupItem };
Loading
Loading