mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* feat(app): tighten the touch composer toolbar onto the text column
On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px
stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the
attach + and the trailing control floated inside the text column. Touch now
places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px
foreground stroke, keeps label-paired glyphs at 20pt, and runs the context
ring at 26pt with a 3px stroke. The row overhangs into the composer padding
by each end control's ink inset (composerToolbarOverhang), so the + ink and
the trailing glyph or send/stop disc sit on the text column's edges.
Pointer sizing is unchanged.
The folded model control now drops its thinking label before the model
label truncates, and the model label truncates on a word boundary
("Ten second…") instead of leaving a space before the ellipsis. Both are
decided from measured widths. The web reports layout widths rounded to whole
pixels, so a tie counts as not fitting.
* fix(app): measure replacement model labels instead of hiding them
The folded model label and TailEllipsisText kept their measuring views mounted
when the label text changed, while discarding the cached widths. A retained
measuring node whose size does not change emits no new layout event on web or
native, so after switching to a model whose label (or a shortened prefix of it)
has the same width, the widths stayed unknown and the label stayed transparent.
Keying each measurer by what it measures remounts it, so every width is
measured again.
Also move the label width sweep into a named helper so the test body states
the expected label order.
* feat(app): space the touch composer toolbar by what it paints
- Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills.
- Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px.
- Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch.
- Stop the picker row's clip from flattening the picker highlight's left edge.
- Budget the density ladder on laid-out widths.
* fix(app): disable the stand-in send disc and keep form model fields in their box
- An empty new chat's stand-in Send is disabled until there is content, as in fullscreen.
- A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins.
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import {
|
|
Pressable,
|
|
type PressableStateCallbackType,
|
|
Text,
|
|
View,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
import Svg, { Circle } from "react-native-svg";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { isNative } from "@/constants/platform";
|
|
import { useHostReportsUsage } from "@/usage";
|
|
import type { Theme } from "@/styles/theme";
|
|
import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry";
|
|
import { ContextWindowDetails } from "./context-window-details";
|
|
import { ContextWindowSheet } from "./context-window-sheet";
|
|
import { formatContextPercentage } from "./context-window-meter.utils";
|
|
|
|
interface ContextWindowMeterProps {
|
|
serverId: string;
|
|
agentId: string;
|
|
maxTokens: number | null;
|
|
usedTokens: number | null;
|
|
totalCostUsd?: number | null;
|
|
showPercentage?: boolean;
|
|
/** The ring's box and stroke when it sits in an icon toolbar. */
|
|
toolbarRing?: ToolbarRing;
|
|
}
|
|
|
|
const SVG_SIZE = 14;
|
|
const USAGE_POPOVER_WIDTH = 300;
|
|
const COMPACT_SVG_SIZE = 12;
|
|
const COMPACT_RADIUS = 5;
|
|
const STROKE_WIDTH = 2;
|
|
const COMPACT_STROKE_WIDTH = 1.75;
|
|
|
|
function isValidMaxTokens(value: number): boolean {
|
|
return Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
function isValidUsedTokens(value: number): boolean {
|
|
return Number.isFinite(value) && value >= 0;
|
|
}
|
|
|
|
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
|
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
|
return null;
|
|
}
|
|
return (usedTokens / maxTokens) * 100;
|
|
}
|
|
|
|
function clampPercentage(value: number): number {
|
|
return Math.max(0, Math.min(100, value));
|
|
}
|
|
|
|
function formatSessionCost(value: number): string | null {
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
return null;
|
|
}
|
|
if (value < 0.01) {
|
|
return `$${value.toFixed(4)}`;
|
|
}
|
|
return `$${value.toFixed(2)}`;
|
|
}
|
|
|
|
function getProgressColor(percentage: number, theme: Theme): string {
|
|
if (percentage > 90) {
|
|
return theme.colors.destructive;
|
|
}
|
|
if (percentage >= 70) {
|
|
return theme.colors.palette.amber[500];
|
|
}
|
|
return theme.colors.foregroundMuted;
|
|
}
|
|
|
|
function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
|
|
if (showPercentage) {
|
|
return {
|
|
svgSize: COMPACT_SVG_SIZE,
|
|
radius: COMPACT_RADIUS,
|
|
strokeWidth: COMPACT_STROKE_WIDTH,
|
|
containerStyle: styles.containerWithLabel,
|
|
};
|
|
}
|
|
const resolvedSize = ring?.size ?? SVG_SIZE;
|
|
const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH;
|
|
return {
|
|
svgSize: resolvedSize,
|
|
radius: (resolvedSize - resolvedStrokeWidth) / 2,
|
|
strokeWidth: resolvedStrokeWidth,
|
|
containerStyle: styles.container,
|
|
};
|
|
}
|
|
|
|
// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG.
|
|
const ContextWindowRing = withUnistyles(function ContextWindowRing({
|
|
size,
|
|
radius,
|
|
strokeWidth,
|
|
percentage,
|
|
trackColor,
|
|
progressColor,
|
|
}: {
|
|
size: number;
|
|
radius: number;
|
|
strokeWidth: number;
|
|
percentage: number | null;
|
|
trackColor: string;
|
|
progressColor: string;
|
|
}) {
|
|
const center = size / 2;
|
|
const circumference = 2 * Math.PI * radius;
|
|
return (
|
|
<Svg
|
|
width={size}
|
|
height={size}
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
{...(isNative
|
|
? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }
|
|
: { "aria-hidden": true })}
|
|
>
|
|
<Circle
|
|
cx={center}
|
|
cy={center}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={trackColor}
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
{percentage !== null ? (
|
|
<Circle
|
|
cx={center}
|
|
cy={center}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={progressColor}
|
|
strokeWidth={strokeWidth}
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={circumference - (clampPercentage(percentage) / 100) * circumference}
|
|
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
|
transform={`rotate(-90 ${center} ${center})`}
|
|
/>
|
|
) : null}
|
|
</Svg>
|
|
);
|
|
});
|
|
|
|
export function ContextWindowMeter({
|
|
serverId,
|
|
agentId,
|
|
maxTokens,
|
|
usedTokens,
|
|
totalCostUsd,
|
|
showPercentage = false,
|
|
toolbarRing,
|
|
}: ContextWindowMeterProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const { width } = useWindowDimensions();
|
|
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
|
const showsUsage = useHostReportsUsage(serverId);
|
|
const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24);
|
|
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
|
const openSheet = useCallback(() => setIsSheetOpen(true), []);
|
|
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
|
|
const percentage =
|
|
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
|
const geometry = getMeterGeometry(showPercentage, toolbarRing);
|
|
|
|
const context = useMemo(
|
|
() =>
|
|
percentage !== null && maxTokens !== null && usedTokens !== null
|
|
? { percentage: Math.round(percentage), maxTokens, usedTokens }
|
|
: null,
|
|
[percentage, maxTokens, usedTokens],
|
|
);
|
|
const meterColors = useCallback(
|
|
(theme: Theme) => ({
|
|
progressColor: getProgressColor(percentage ?? 0, theme),
|
|
trackColor: theme.colors.surface3,
|
|
}),
|
|
[percentage],
|
|
);
|
|
const formattedSessionCost =
|
|
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
|
const containerStyle = geometry.containerStyle;
|
|
const pressableStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType) => [containerStyle, pressed && styles.pressed],
|
|
[containerStyle],
|
|
);
|
|
const ring = (
|
|
<ContextWindowRing
|
|
size={geometry.svgSize}
|
|
radius={geometry.radius}
|
|
strokeWidth={geometry.strokeWidth}
|
|
percentage={percentage}
|
|
uniProps={meterColors}
|
|
/>
|
|
);
|
|
const percentageLabel =
|
|
showPercentage && context ? (
|
|
<Text style={styles.percentageLabel}>
|
|
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
|
|
</Text>
|
|
) : null;
|
|
const accessibilityLabel = context
|
|
? t("contextWindow.accessibility", { percentage: context.percentage })
|
|
: t("contextWindow.accessibilityNoData");
|
|
|
|
if (isCompact) {
|
|
return (
|
|
<>
|
|
<Pressable
|
|
style={pressableStyle}
|
|
testID="context-window-meter"
|
|
accessibilityRole="button"
|
|
accessibilityLabel={accessibilityLabel}
|
|
onPress={openSheet}
|
|
>
|
|
{ring}
|
|
{percentageLabel}
|
|
</Pressable>
|
|
<ContextWindowSheet open={isSheetOpen} onClose={closeSheet}>
|
|
<ContextWindowDetails
|
|
serverId={serverId}
|
|
agentId={agentId}
|
|
context={context}
|
|
sessionCost={formattedSessionCost}
|
|
showTitle={false}
|
|
refreshable
|
|
/>
|
|
</ContextWindowSheet>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const popoverStyle = showsUsage
|
|
? [styles.usagePopover, { width: popoverWidth }]
|
|
: styles.plainPopover;
|
|
|
|
// Native wide screens have no hover, so the details open in a tooltip on press. The tooltip
|
|
// takes no presses, so its usage cards have no Refresh.
|
|
if (isNative) {
|
|
return (
|
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
|
<TooltipTrigger asChild triggerRefProp="ref">
|
|
<Pressable
|
|
style={pressableStyle}
|
|
testID="context-window-meter"
|
|
accessibilityRole="image"
|
|
accessibilityLabel={accessibilityLabel}
|
|
>
|
|
{ring}
|
|
{percentageLabel}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="top"
|
|
align="center"
|
|
offset={8}
|
|
maxWidth={showsUsage ? popoverWidth : undefined}
|
|
style={popoverStyle}
|
|
testID="context-window-meter-tooltip"
|
|
>
|
|
<ContextWindowDetails
|
|
serverId={serverId}
|
|
agentId={agentId}
|
|
context={context}
|
|
sessionCost={formattedSessionCost}
|
|
showTitle
|
|
refreshable={false}
|
|
/>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<HoverCard>
|
|
<HoverCardTrigger focusable accessibilityLabel={accessibilityLabel}>
|
|
<View
|
|
style={containerStyle}
|
|
testID="context-window-meter"
|
|
accessibilityRole="image"
|
|
accessibilityLabel={accessibilityLabel}
|
|
>
|
|
{ring}
|
|
{percentageLabel}
|
|
</View>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
placement="top"
|
|
offset={8}
|
|
role="dialog"
|
|
accessibilityLabel={t("contextWindow.title")}
|
|
testID="context-window-details"
|
|
style={popoverStyle}
|
|
>
|
|
<ContextWindowDetails
|
|
serverId={serverId}
|
|
agentId={agentId}
|
|
context={context}
|
|
sessionCost={formattedSessionCost}
|
|
showTitle
|
|
refreshable
|
|
/>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
width: TOOLBAR_DISC_SIZE,
|
|
height: TOOLBAR_DISC_SIZE,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
containerWithLabel: {
|
|
height: TOOLBAR_DISC_SIZE,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.full,
|
|
},
|
|
pressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
percentageLabel: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
// Plain details use a small inset; account usage cards have their own content density.
|
|
plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] },
|
|
usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] },
|
|
}));
|