Merge branch 'main' into dev
This commit is contained in:
@@ -88,6 +88,7 @@ type ControlledAgentStatusBarProps = {
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
};
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
@@ -110,6 +111,7 @@ export interface DraftAgentStatusBarProps {
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -217,6 +219,7 @@ function ControlledStatusBar({
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -411,6 +414,7 @@ function ControlledStatusBar({
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
</View>
|
||||
@@ -662,6 +666,7 @@ function ControlledStatusBar({
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
renderTrigger={({ selectedModelLabel }) => (
|
||||
<View
|
||||
@@ -875,6 +880,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
invalidate: invalidateSnapshot,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
@@ -1035,6 +1041,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
});
|
||||
}}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onModelSelectorOpen={invalidateSnapshot}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1061,6 +1068,7 @@ export function DraftAgentStatusBar({
|
||||
features,
|
||||
onSetFeature,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -1105,6 +1113,7 @@ export function DraftAgentStatusBar({
|
||||
}}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
@@ -1154,6 +1163,7 @@ export function DraftAgentStatusBar({
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onModelSelectorOpen={onModelSelectorOpen}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
currentBranchName: string | null;
|
||||
title: string;
|
||||
branchOptions: ComboboxOption[];
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBranchSelect: (branchId: string) => void;
|
||||
}
|
||||
|
||||
export function BranchSwitcher({
|
||||
currentBranchName,
|
||||
title,
|
||||
branchOptions,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onBranchSelect,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return (
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
testID="workspace-header-branch-switcher"
|
||||
onPress={() => onOpenChange(true)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.branchSwitcherTrigger,
|
||||
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
<GitBranch
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
value={currentBranchName}
|
||||
onSelect={onBranchSelect}
|
||||
searchable
|
||||
placeholder="Switch branch..."
|
||||
searchPlaceholder="Filter branches..."
|
||||
emptyText="No branches found."
|
||||
title="Switch branch"
|
||||
open={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
desktopMinWidth={280}
|
||||
renderOption={({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={
|
||||
<GitBranch
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: {
|
||||
xs: "400",
|
||||
md: "300",
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
branchSwitcherTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
branchSwitcherTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
}));
|
||||
@@ -59,6 +59,7 @@ interface CombinedModelSelectorProps {
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
}) => React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -517,6 +518,7 @@ export function CombinedModelSelector({
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavorite,
|
||||
renderTrigger,
|
||||
onOpen,
|
||||
onClose,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
@@ -553,12 +555,14 @@ export function CombinedModelSelector({
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
setView(computeInitialView());
|
||||
if (!open) {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
setSearchQuery("");
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onClose, computeInitialView],
|
||||
[onOpen, onClose, computeInitialView],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
|
||||
@@ -109,6 +109,7 @@ export function ToastViewport({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -182,7 +183,6 @@ export function ToastViewport({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildVisibleComboboxOptions,
|
||||
filterAndRankComboboxOptions,
|
||||
getComboboxFallbackIndex,
|
||||
orderVisibleComboboxOptions,
|
||||
} from "./combobox-options";
|
||||
@@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankComboboxOptions", () => {
|
||||
const options = [
|
||||
{ id: "feat/login", label: "feat/login" },
|
||||
{ id: "main", label: "main" },
|
||||
{ id: "feat/main-nav", label: "feat/main-nav" },
|
||||
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
|
||||
];
|
||||
|
||||
it("returns all options when search is empty", () => {
|
||||
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
|
||||
});
|
||||
|
||||
it("filters by label substring", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "login");
|
||||
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
|
||||
});
|
||||
|
||||
it("filters by id substring", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "fix/");
|
||||
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||
});
|
||||
|
||||
it("filters by description substring", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "logout bug");
|
||||
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||
});
|
||||
|
||||
it("ranks prefix matches above substring matches", () => {
|
||||
const result = filterAndRankComboboxOptions(options, "main");
|
||||
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const items = [{ id: "Alpha", label: "Alpha" }];
|
||||
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty when nothing matches", () => {
|
||||
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combobox above-search ordering", () => {
|
||||
const visible = [
|
||||
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||
|
||||
@@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: {
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAndRankComboboxOptions(
|
||||
options: ComboboxOptionModel[],
|
||||
search: string,
|
||||
): ComboboxOptionModel[] {
|
||||
if (!search) return options;
|
||||
return options
|
||||
.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(search) ||
|
||||
opt.id.toLowerCase().includes(search) ||
|
||||
opt.description?.toLowerCase().includes(search),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aPrefix =
|
||||
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
||||
const bPrefix =
|
||||
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
||||
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVisibleComboboxOptions(
|
||||
input: BuildVisibleComboboxOptionsInput,
|
||||
): ComboboxOptionModel[] {
|
||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||
const filteredOptions = normalizedSearch
|
||||
? input.options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.description?.toLowerCase().includes(normalizedSearch),
|
||||
)
|
||||
: input.options;
|
||||
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
|
||||
|
||||
const sanitizedSearchValue = input.searchQuery.trim();
|
||||
const showCustomOption = shouldShowCustomComboboxOption({
|
||||
|
||||
Reference in New Issue
Block a user