Fix compact composer controls and native scrolling (#2361)
* fix(app): refine compact composer controls and native scrolling Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport. * fix(app): suppress recoverable resume refresh errors Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect. * fix(app): stabilize compact model sheet loading Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection. * fix(app): preserve route anchors during native scroll * fix(app): preserve native scroll intent and sheet dismissal * fix(app): keep compact sheet styles composable
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { forwardRef, useCallback, type ComponentType } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
|
||||
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||
|
||||
export interface AgentControlIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
|
||||
|
||||
interface AgentControlTriggerProps {
|
||||
icon: AgentControlIcon;
|
||||
iconColor?: string;
|
||||
surface: "toolbar" | "sheet";
|
||||
label: string;
|
||||
value?: string;
|
||||
showToolbarLabel?: boolean;
|
||||
showCaret?: boolean;
|
||||
open?: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
accessibilityLabel: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||
function AgentControlTrigger(
|
||||
{
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
surface,
|
||||
label,
|
||||
value,
|
||||
showToolbarLabel = true,
|
||||
showCaret = false,
|
||||
open = false,
|
||||
disabled = false,
|
||||
onPress,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { glyphSize } = useComposerControlLayout();
|
||||
const isSheet = surface === "sheet";
|
||||
const resolvedGlyphSize = isSheet ? 16 : glyphSize;
|
||||
const resolvedIconColor = iconColor ?? styles.iconColor.color;
|
||||
const showValue = isSheet || showToolbarLabel;
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType) => [
|
||||
isSheet ? styles.sheetRow : styles.toolbarControl,
|
||||
!isSheet && !showToolbarLabel && styles.toolbarIconOnly,
|
||||
hovered && (isSheet ? styles.sheetRowInteractive : styles.hovered),
|
||||
(pressed || open) && (isSheet ? styles.sheetRowInteractive : styles.pressed),
|
||||
disabled && styles.disabled,
|
||||
],
|
||||
[disabled, isSheet, open, showToolbarLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxTrigger
|
||||
ref={ref}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
chevron={showCaret ? undefined : null}
|
||||
>
|
||||
{isSheet ? (
|
||||
<View style={styles.sheetGlyph}>
|
||||
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
|
||||
</View>
|
||||
) : (
|
||||
<ComposerToolbarGlyph size={resolvedGlyphSize}>
|
||||
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
|
||||
</ComposerToolbarGlyph>
|
||||
)}
|
||||
{isSheet ? (
|
||||
<Text style={styles.sheetLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
{showValue ? (
|
||||
<Text style={isSheet ? styles.sheetValue : styles.toolbarValue} numberOfLines={1}>
|
||||
{value ?? label}
|
||||
</Text>
|
||||
) : null}
|
||||
</ComboboxTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
toolbarControl: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
toolbarIconOnly: {
|
||||
width: 28,
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
},
|
||||
toolbarValue: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sheetRow: {
|
||||
minHeight: 44,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginHorizontal: -theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
sheetRowInteractive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sheetGlyph: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sheetLabel: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sheetValue: {
|
||||
maxWidth: "45%",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
hovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
|
||||
return (
|
||||
<View
|
||||
style={size >= 20 ? styles.native : styles.web}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
web: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
native: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
|
||||
|
||||
interface ComposerControlLayoutValue {
|
||||
glyphSize: number;
|
||||
presentation: ComposerControlPresentation;
|
||||
}
|
||||
|
||||
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
|
||||
glyphSize: 16,
|
||||
presentation: {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
},
|
||||
};
|
||||
|
||||
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
|
||||
|
||||
export function ComposerControlLayoutProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerControlLayoutValue;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ComposerControlLayoutContext.Provider value={value}>
|
||||
{children}
|
||||
</ComposerControlLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerControlLayout(): ComposerControlLayoutValue {
|
||||
return useContext(ComposerControlLayoutContext);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COMPOSER_TOOLBAR_GEOMETRY,
|
||||
resolveComposerControlDensity,
|
||||
resolveComposerControlPresentation,
|
||||
resolveComposerToolbarGlyphSize,
|
||||
} from "./layout";
|
||||
|
||||
describe("composer control layout", () => {
|
||||
it("removes labels in priority order as the toolbar narrows", () => {
|
||||
expect(resolveComposerControlPresentation("full")).toEqual({
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("condensed")).toEqual({
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("tight")).toEqual({
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local available width and hysteresis to avoid density churn", () => {
|
||||
const controls = {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 420,
|
||||
currentDensity: "full",
|
||||
controls,
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 380,
|
||||
currentDensity: "full",
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 290,
|
||||
currentDensity: "condensed",
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 280,
|
||||
currentDensity: "condensed",
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 300,
|
||||
currentDensity: "tight",
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 312,
|
||||
currentDensity: "tight",
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("budgets extra features and larger text before restoring full labels", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "condensed" as const,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
fontScale: 1.25,
|
||||
},
|
||||
}),
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("condenses before a labeled feature would overflow", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "full" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: { ...base.controls, features: [{ type: "toggle" }] },
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
...base.controls,
|
||||
features: [{ type: "select", label: "A much longer localized feature label" }],
|
||||
},
|
||||
}),
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one platform glyph envelope", () => {
|
||||
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
||||
controlSize: 28,
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
});
|
||||
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
|
||||
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
export type ComposerControlDensity = "full" | "condensed" | "tight";
|
||||
|
||||
export interface ComposerControlPresence {
|
||||
hasModel: boolean;
|
||||
hasThinking: boolean;
|
||||
hasMode: boolean;
|
||||
features: readonly ComposerFeatureControlPresence[];
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
|
||||
|
||||
export interface ComposerControlPresentation {
|
||||
showCarets: boolean;
|
||||
showThinkingLabel: boolean;
|
||||
showModeLabel: boolean;
|
||||
aggregateFeatures: boolean;
|
||||
}
|
||||
|
||||
export const COMPOSER_TOOLBAR_GEOMETRY = {
|
||||
controlSize: 28,
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
} as const;
|
||||
|
||||
const DENSITY_HYSTERESIS = 12;
|
||||
|
||||
function normalizedFontScale(fontScale: number): number {
|
||||
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
|
||||
}
|
||||
|
||||
function sumControlWidths(widths: number[]): number {
|
||||
if (widths.length === 0) return 0;
|
||||
return (
|
||||
widths.reduce((total, width) => total + width, 0) +
|
||||
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
|
||||
);
|
||||
}
|
||||
|
||||
function estimateLabelWidth(label: string, fontScale: number): number {
|
||||
return Array.from(label).length * 7 * fontScale;
|
||||
}
|
||||
|
||||
function resolveFeatureControlWidth(
|
||||
feature: ComposerFeatureControlPresence,
|
||||
fontScale: number,
|
||||
): number {
|
||||
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
|
||||
return (
|
||||
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
|
||||
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
|
||||
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
|
||||
estimateLabelWidth(feature.label, fontScale)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCondensedFloor(controls: ComposerControlPresence): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) widths.push(36 + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.hasMode) widths.push(36 + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
function resolveFullFloor(controls: ComposerControlPresence): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) widths.push(50 + 70 * fontScale);
|
||||
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
|
||||
if (controls.hasMode) widths.push(54 + 96 * fontScale);
|
||||
for (const feature of controls.features) {
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale));
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
export function resolveComposerControlDensity(input: {
|
||||
availableWidth: number;
|
||||
currentDensity: ComposerControlDensity;
|
||||
controls: ComposerControlPresence;
|
||||
}): ComposerControlDensity {
|
||||
const fullFloor = resolveFullFloor(input.controls);
|
||||
const condensedFloor = resolveCondensedFloor(input.controls);
|
||||
|
||||
if (input.currentDensity === "full") {
|
||||
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
|
||||
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
|
||||
}
|
||||
|
||||
if (input.currentDensity === "condensed") {
|
||||
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
|
||||
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
|
||||
return "condensed";
|
||||
}
|
||||
|
||||
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
|
||||
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
|
||||
return "tight";
|
||||
}
|
||||
|
||||
export function resolveComposerControlPresentation(
|
||||
density: ComposerControlDensity,
|
||||
): ComposerControlPresentation {
|
||||
if (density === "full") {
|
||||
return {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
};
|
||||
}
|
||||
if (density === "condensed") {
|
||||
return {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
|
||||
return platform === "native" ? 20 : 16;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
ShieldPlus,
|
||||
ShieldQuestionMark,
|
||||
} from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -32,7 +30,6 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
@@ -41,15 +38,11 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export type AgentModeControlPlacement = "toolbar" | "footer";
|
||||
|
||||
function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) {
|
||||
return placement === "footer" ? isCompact : !isCompact;
|
||||
}
|
||||
|
||||
interface ModeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
@@ -102,7 +95,7 @@ function ModeComboboxOption({
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentModeControlViewProps {
|
||||
export interface AgentModeControlValue {
|
||||
provider: string;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
@@ -115,20 +108,24 @@ function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function AgentModeControlView({
|
||||
export function AgentModeControl({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedModeId,
|
||||
onSelectMode,
|
||||
disabled = false,
|
||||
}: AgentModeControlViewProps) {
|
||||
surface = "toolbar",
|
||||
onClose,
|
||||
}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { presentation } = useComposerControlLayout();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const openRef = useRef(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -140,7 +137,7 @@ function AgentModeControlView({
|
||||
const visuals = selectedMode
|
||||
? getModeVisuals(provider, selectedMode.id, providerDefinitions)
|
||||
: undefined;
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined;
|
||||
const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot;
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
|
||||
|
||||
@@ -154,10 +151,18 @@ function AgentModeControlView({
|
||||
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [allOptions, searchQuery]);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchQuery("");
|
||||
}, []);
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
const wasOpen = openRef.current;
|
||||
openRef.current = next;
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setSearchQuery("");
|
||||
if (wasOpen) onClose?.();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
|
||||
const handleSelect = useCallback(
|
||||
@@ -208,18 +213,6 @@ function AgentModeControlView({
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType) => [
|
||||
styles.chip,
|
||||
hovered && styles.chipHovered,
|
||||
(pressed || open) && styles.chipPressed,
|
||||
disabled && styles.chipDisabled,
|
||||
],
|
||||
[open, disabled],
|
||||
);
|
||||
|
||||
const labelStyle = styles.chipLabel;
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("agentControls.mode.title"),
|
||||
@@ -238,21 +231,23 @@ function AgentModeControlView({
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
icon={Icon}
|
||||
iconColor={iconColor}
|
||||
surface={surface}
|
||||
label={t("agentControls.mode.title")}
|
||||
value={selectedModeLabel}
|
||||
showToolbarLabel={presentation.showModeLabel}
|
||||
showCaret={surface === "toolbar" && presentation.showCarets}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -282,21 +277,10 @@ function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
|
||||
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
interface AgentModeControlProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export const AgentModeControl = memo(function AgentModeControl({
|
||||
serverId,
|
||||
agentId,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: AgentModeControlProps) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
export function useLiveAgentModeControl(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): AgentModeControlValue | null {
|
||||
const slice = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
@@ -349,82 +333,20 @@ export const AgentModeControl = memo(function AgentModeControl({
|
||||
[agentId, client, slice?.provider, toast, updatePreferences],
|
||||
);
|
||||
|
||||
if (!slice || availableModes.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
|
||||
return (
|
||||
<AgentModeControlView
|
||||
provider={slice.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={availableModes}
|
||||
selectedModeId={slice.currentModeId}
|
||||
onSelectMode={handleSelectMode}
|
||||
disabled={!client}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export interface DraftAgentModeControlProps {
|
||||
selectedProvider: AgentProvider | null;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
onSelectMode: (modeId: string) => void;
|
||||
disabled?: boolean;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export function DraftAgentModeControl({
|
||||
selectedProvider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
disabled,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: DraftAgentModeControlProps) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
if (!selectedProvider || modeOptions.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
return (
|
||||
<AgentModeControlView
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={modeOptions}
|
||||
selectedModeId={selectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
return useMemo(() => {
|
||||
if (!slice || availableModes.length === 0) return null;
|
||||
return {
|
||||
provider: slice.provider,
|
||||
providerDefinitions,
|
||||
modeOptions: availableModes,
|
||||
selectedModeId: slice.currentModeId,
|
||||
onSelectMode: handleSelectMode,
|
||||
disabled: !client,
|
||||
};
|
||||
}, [availableModes, client, handleSelectMode, providerDefinitions, slice]);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chip: {
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
chipHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
chipDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
chipLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
const SNAP_POINTS = ["80%", "90%"];
|
||||
const MODEL_LIST_TOP_INSET = 4;
|
||||
const MODEL_ROW_STRIDE = 44;
|
||||
const MODEL_VIEWPORT_VISIBLE_ROWS = 4.5;
|
||||
const FIXED_MODEL_VIEWPORT_HEIGHT =
|
||||
MODEL_LIST_TOP_INSET + MODEL_ROW_STRIDE * MODEL_VIEWPORT_VISIBLE_ROWS;
|
||||
|
||||
interface CompactModelSheetProps {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
isLoading: boolean;
|
||||
favoriteKeys: Set<string>;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
glyphSize: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function shortModelLabel(label: string): string {
|
||||
const separatorIndex = label.lastIndexOf("/");
|
||||
return separatorIndex === -1 ? label : label.slice(separatorIndex + 1);
|
||||
}
|
||||
|
||||
export function CompactModelSheet({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
favoriteKeys,
|
||||
onToggleFavorite,
|
||||
onOpen,
|
||||
onClose,
|
||||
onRetryProvider,
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
glyphSize,
|
||||
children,
|
||||
}: CompactModelSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const usesBottomSheet = useIsCompactFormFactor();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const browser = useModelBrowser({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
isLoading,
|
||||
favoriteKeys,
|
||||
serverId,
|
||||
});
|
||||
const { prepareToOpen, reset } = browser;
|
||||
const ProviderIcon =
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
|
||||
const compactFooter = useMemo(
|
||||
() =>
|
||||
usesBottomSheet ? (
|
||||
<View style={styles.compactFooter} testID="agent-controls-settings-list">
|
||||
<View style={styles.modelViewportDivider} />
|
||||
<View style={[styles.controlsContent, styles.compactControlsContent]}>{children}</View>
|
||||
</View>
|
||||
) : undefined,
|
||||
[children, usesBottomSheet],
|
||||
);
|
||||
|
||||
const open = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
prepareToOpen();
|
||||
setIsOpen(true);
|
||||
onOpen?.();
|
||||
}, [onOpen, prepareToOpen]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
reset();
|
||||
onClose?.();
|
||||
}, [onClose, reset]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider, modelId);
|
||||
close();
|
||||
},
|
||||
[close, onSelect],
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isOpen) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
open();
|
||||
}, [close, isOpen, open]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
],
|
||||
[disabled, isOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={toggle}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("modelSelector.selectedModel", {
|
||||
model: browser.selectedModelLabel,
|
||||
})}
|
||||
testID="combined-model-selector"
|
||||
chevron={null}
|
||||
>
|
||||
{ProviderIcon ? (
|
||||
<ComposerToolbarGlyph size={glyphSize}>
|
||||
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
|
||||
</ComposerToolbarGlyph>
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1}>
|
||||
{shortModelLabel(browser.triggerLabel)}
|
||||
</Text>
|
||||
</ComboboxTrigger>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
header={browser.header}
|
||||
visible={isOpen}
|
||||
onClose={close}
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
sizeContentToCurrentSnapPoint={usesBottomSheet}
|
||||
footer={compactFooter}
|
||||
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
|
||||
contentContainerStyle={styles.sheetBody}
|
||||
testID="agent-controls-model-sheet"
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modelViewport,
|
||||
usesBottomSheet ? styles.flexibleModelViewport : styles.fixedModelViewport,
|
||||
]}
|
||||
testID="agent-controls-model-viewport"
|
||||
>
|
||||
<ModelBrowser
|
||||
state={browser}
|
||||
onSelect={handleSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onRetryProvider={onRetryProvider}
|
||||
isRetryingProvider={isRetryingProvider}
|
||||
scrolling="independent"
|
||||
/>
|
||||
</View>
|
||||
{!usesBottomSheet ? (
|
||||
<>
|
||||
<View style={styles.modelViewportDivider} />
|
||||
<ScrollView
|
||||
style={styles.controlsScroll}
|
||||
contentContainerStyle={styles.controlsContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="agent-controls-settings-list"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
providerIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sheetBody: {
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
gap: 0,
|
||||
},
|
||||
modelViewport: {
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
flexibleModelViewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
fixedModelViewport: {
|
||||
height: FIXED_MODEL_VIEWPORT_HEIGHT,
|
||||
minHeight: FIXED_MODEL_VIEWPORT_HEIGHT,
|
||||
},
|
||||
modelViewportDivider: {
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
controlsScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
compactFooterContainer: {
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
gap: 0,
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
compactFooter: {
|
||||
minWidth: 0,
|
||||
},
|
||||
compactControlsContent: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
controlsContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -9,7 +9,6 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
@@ -609,57 +608,6 @@ export function WorkspaceDraftAgentTab({
|
||||
focusInputRef.current = focus;
|
||||
}, []);
|
||||
|
||||
const handleProviderSelectWithFocus = useCallback(
|
||||
(provider: Parameters<typeof composerState.setProviderFromUser>[0]) => {
|
||||
composerState.setProviderFromUser(provider);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleModeSelectWithFocus = useCallback(
|
||||
(modeId: string) => {
|
||||
composerState.setModeFromUser(modeId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleModelSelectWithFocus = useCallback(
|
||||
(modelId: string) => {
|
||||
composerState.setModelFromUser(modelId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleProviderAndModelSelectWithFocus = useCallback(
|
||||
(
|
||||
provider: Parameters<typeof composerState.setProviderAndModelFromUser>[0],
|
||||
modelId: string,
|
||||
) => {
|
||||
composerState.setProviderAndModelFromUser(provider, modelId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleThinkingOptionSelectWithFocus = useCallback(
|
||||
(optionId: string) => {
|
||||
composerState.setThinkingOptionFromUser(optionId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleSetFeatureWithFocus = useCallback(
|
||||
(featureId: string, value: unknown) => {
|
||||
composerState.agentControls.onSetFeature?.(featureId, value);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
@@ -680,39 +628,11 @@ export function WorkspaceDraftAgentTab({
|
||||
const composerAgentControls = useMemo(
|
||||
() => ({
|
||||
...composerState.agentControls,
|
||||
onSelectProvider: handleProviderSelectWithFocus,
|
||||
onSelectMode: handleModeSelectWithFocus,
|
||||
onSelectModel: handleModelSelectWithFocus,
|
||||
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
|
||||
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
|
||||
onSetFeature: handleSetFeatureWithFocus,
|
||||
onDropdownClose: handleDropdownCloseFocus,
|
||||
disabled: isSubmitting,
|
||||
}),
|
||||
[
|
||||
composerState.agentControls,
|
||||
handleProviderSelectWithFocus,
|
||||
handleModeSelectWithFocus,
|
||||
handleModelSelectWithFocus,
|
||||
handleProviderAndModelSelectWithFocus,
|
||||
handleThinkingOptionSelectWithFocus,
|
||||
handleSetFeatureWithFocus,
|
||||
handleDropdownCloseFocus,
|
||||
isSubmitting,
|
||||
],
|
||||
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
|
||||
);
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompactComposerLayout ? (
|
||||
<DraftAgentModeControl
|
||||
placement="footer"
|
||||
{...composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompactComposerLayout, composerAgentControls],
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
@@ -770,7 +690,6 @@ export function WorkspaceDraftAgentTab({
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -232,6 +232,7 @@ function renderContextWindowMeter(
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
pending: boolean,
|
||||
glyphSize: number,
|
||||
): ReactElement | null {
|
||||
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
|
||||
if (!hasData && !pending) {
|
||||
@@ -246,21 +247,16 @@ function renderContextWindowMeter(
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
pending={pending}
|
||||
glyphSize={glyphSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveContextWindowPlacement(
|
||||
meter: ReactElement | null,
|
||||
isMobile: boolean,
|
||||
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
|
||||
if (isMobile) {
|
||||
return { beforeVoiceContent: null, footerInlineContent: meter };
|
||||
}
|
||||
return {
|
||||
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
|
||||
footerInlineContent: null,
|
||||
};
|
||||
reserveSlot: boolean,
|
||||
): ReactNode {
|
||||
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
|
||||
}
|
||||
|
||||
interface RenderLeftContentArgs {
|
||||
@@ -302,23 +298,6 @@ interface RenderAttachmentTrayArgs {
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposerFooter(
|
||||
footer: ReactNode,
|
||||
footerInlineContent: ReactNode,
|
||||
): ReactElement | null {
|
||||
if (!footer && !footerInlineContent) return null;
|
||||
return (
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerContent}>
|
||||
<View style={styles.footerLeft}>
|
||||
{footer}
|
||||
{footerInlineContent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
|
||||
const {
|
||||
selectedAttachments,
|
||||
@@ -861,8 +840,6 @@ interface ComposerProps {
|
||||
agentControls?: DraftAgentControlsProps;
|
||||
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
|
||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||
/** Rendered below the input, inside the keyboard-shifted container. */
|
||||
footer?: ReactNode;
|
||||
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
@@ -1072,7 +1049,6 @@ export function Composer({
|
||||
onAttentionPromptSend,
|
||||
agentControls,
|
||||
inputWrapperStyle,
|
||||
footer,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
@@ -1800,6 +1776,7 @@ export function Composer({
|
||||
|
||||
const contextWindowPending =
|
||||
agentState.status === "initializing" || agentState.status === "running";
|
||||
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
|
||||
|
||||
const contextWindowMeter = useMemo(
|
||||
() =>
|
||||
@@ -1807,24 +1784,25 @@ export function Composer({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
false,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
[
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
|
||||
[contextWindowMeter, hasAgent],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
@@ -2155,7 +2133,6 @@ export function Composer({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
@@ -2191,50 +2168,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footer: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
// Negative margin pulls the footer up against the input area's paddingBottom.
|
||||
// On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more.
|
||||
marginTop: {
|
||||
xs: -(theme.spacing[4] - 3),
|
||||
md: -theme.spacing[3],
|
||||
},
|
||||
alignItems: "center",
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
footerContent: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
// On mobile, the negative margins below cancel each glyph's internal padding
|
||||
// to reach the composer border; this inset adds a small visual gap from it.
|
||||
paddingLeft: {
|
||||
xs: 5,
|
||||
md: 10,
|
||||
},
|
||||
paddingRight: {
|
||||
xs: 5,
|
||||
md: 10,
|
||||
},
|
||||
},
|
||||
footerLeft: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
|
||||
// so its icon aligns to the composer border before the footer inset is applied.
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
messageInputContainer: {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
@@ -2257,6 +2190,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user