refactor: standardize status badges and fix composer whitespace handling

This commit is contained in:
Mohamed Boudra
2026-08-16 19:14:04 +02:00
parent 3136117906
commit 90c97c1b1a
16 changed files with 457 additions and 163 deletions
+5 -49
View File
@@ -22,6 +22,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { HighlightedText } from "@/components/ui/highlighted-text";
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
@@ -115,28 +116,10 @@ function SessionBadge({
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
const badgeStyle = useMemo(
() => [
styles.badge,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
],
[tone],
);
const badgeTextStyle = useMemo(
() => [
styles.badgeText,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
],
[tone],
);
return (
<View style={badgeStyle}>
{icon}
<Text style={badgeTextStyle}>{label}</Text>
</View>
);
let variant: StatusBadgeVariant = "muted";
if (tone === "warning") variant = "warning";
else if (tone === "danger") variant = "error";
return <StatusBadge label={label} variant={variant} leading={icon} />;
}
function WorkspaceTitlePrefix({
@@ -742,33 +725,6 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[4],
textAlign: "right" as const,
},
badge: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
badgeWarning: {
backgroundColor: "rgba(245, 158, 11, 0.12)",
},
badgeDanger: {
backgroundColor: "rgba(239, 68, 68, 0.14)",
},
badgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
badgeTextWarning: {
color: theme.colors.palette.amber[500],
},
badgeTextDanger: {
color: theme.colors.palette.red[300],
},
sheetOverlay: {
flex: 1,
justifyContent: "flex-end",
@@ -0,0 +1,61 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { StatusBadge } from "./status-badge";
interface MountedBadge {
root: Root;
container: HTMLDivElement;
}
const mountedBadges: MountedBadge[] = [];
function mountBadge(variant: "success" | "warning" | "error" | "muted"): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<StatusBadge label="Status" variant={variant} />));
mountedBadges.push({ root, container });
const badge = container.firstElementChild;
if (!(badge instanceof HTMLElement)) {
throw new Error("StatusBadge did not render a badge element");
}
return badge;
}
afterEach(() => {
for (const mounted of mountedBadges.splice(0)) {
act(() => mounted.root.unmount());
mounted.container.remove();
}
});
describe("StatusBadge", () => {
it.each(["success", "warning", "error", "muted"] as const)(
"uses the semantic badge shell for the %s variant",
(variant) => {
const badge = mountBadge(variant);
const style = getComputedStyle(badge);
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
expect(style.borderColor).toBe("rgb(228, 228, 231)");
},
);
it.each([
["success", "rgb(21, 128, 61)"],
["warning", "rgb(217, 119, 6)"],
["error", "rgb(185, 28, 28)"],
["muted", "rgb(102, 102, 102)"],
] as const)("uses the semantic %s signal for its text", (variant, expectedColor) => {
const badge = mountBadge(variant);
const text = badge.lastElementChild;
if (!(text instanceof HTMLElement)) {
throw new Error("StatusBadge did not render its label");
}
expect(getComputedStyle(text).color).toBe(expectedColor);
});
});
+11 -22
View File
@@ -1,34 +1,29 @@
import { useMemo } from "react";
import React, { useMemo, type ReactNode } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
type StatusBadgeVariant = "success" | "error" | "muted";
export type StatusBadgeVariant = "success" | "warning" | "error" | "muted";
interface StatusBadgeProps {
label: string;
variant?: StatusBadgeVariant;
leading?: ReactNode;
}
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
const pillStyle = useMemo(
() => [
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
],
[variant],
);
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
const textStyle = useMemo(
() => [
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "warning" && styles.pillTextWarning,
variant === "error" && styles.pillTextError,
],
[variant],
);
return (
<View style={pillStyle}>
<View style={styles.pill}>
{leading}
<Text style={textStyle}>{label}</Text>
</View>
);
@@ -38,6 +33,7 @@ const styles = StyleSheet.create((theme) => ({
pill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -45,16 +41,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
paddingVertical: 3,
},
// Tinted from the one status token rather than a palette step, so the pill tracks the
// theme. `1a`/`33` are the 10%/20% alpha suffixes the identity table uses.
pillSuccess: {
backgroundColor: `${theme.colors.statusSuccess}1a`,
borderColor: `${theme.colors.statusSuccess}33`,
},
pillError: {
backgroundColor: `${theme.colors.statusDanger}1a`,
borderColor: `${theme.colors.statusDanger}33`,
},
pillText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
@@ -63,6 +49,9 @@ const styles = StyleSheet.create((theme) => ({
pillTextSuccess: {
color: theme.colors.statusSuccess,
},
pillTextWarning: {
color: theme.colors.statusWarning,
},
pillTextError: {
color: theme.colors.statusDanger,
},
@@ -57,7 +57,7 @@ export function hasDraftContent(input: {
text: string;
attachments: UserComposerAttachment[];
}): boolean {
return input.text.trim().length > 0 || input.attachments.length > 0;
return input.text.length > 0 || input.attachments.length > 0;
}
export function areAttachmentsEqual(input: {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveDraftKey } from "./input-draft-core";
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
@@ -26,6 +26,12 @@ describe("resolveDraftKey", () => {
});
});
describe("hasDraftContent", () => {
it("preserves whitespace-only text while the user is editing", () => {
expect(hasDraftContent({ text: "\n\n\n\n\n", attachments: [] })).toBe(true);
});
});
describe("resolveEffectiveComposerModelId", () => {
it("returns the selected model trimmed", () => {
expect(
@@ -8,6 +8,7 @@ interface Args {
onHeight: (height: number) => void;
}
export function useComposerHeightMirror(_args: Args): void {
export function useComposerHeightMirror(_args: Args): (_value: string) => void {
// No-op on native: onContentSizeChange drives height natively.
return () => undefined;
}
@@ -37,7 +37,7 @@ export function useComposerHeightMirror({
minHeight,
maxHeight,
onHeight,
}: Args): void {
}: Args): (value: string) => void {
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
paramsRef.current = { value, minHeight, maxHeight, onHeight };
@@ -70,34 +70,36 @@ export function useComposerHeightMirror({
};
}, []);
const measure = useCallback(() => {
const mirror = mirrorRef.current;
const source = textareaRef.current;
if (!mirror || !source || typeof window === "undefined") return;
if (!(source instanceof HTMLElement)) return;
const measure = useCallback(
(text: string) => {
const mirror = mirrorRef.current;
const source = textareaRef.current;
if (!mirror || !source || typeof window === "undefined") return;
if (!(source instanceof HTMLElement)) return;
const cs = window.getComputedStyle(source);
const ms = mirror.style;
for (const prop of COPIED_STYLES) {
ms[prop] = cs[prop];
}
ms.width = `${source.clientWidth}px`;
const cs = window.getComputedStyle(source);
const ms = mirror.style;
for (const prop of COPIED_STYLES) {
ms[prop] = cs[prop];
}
ms.width = `${source.clientWidth}px`;
const {
value: currentValue,
minHeight: currentMinHeight,
maxHeight: currentMaxHeight,
onHeight: currentOnHeight,
} = paramsRef.current;
// Trailing newline is collapsed by textarea measurement — pad with a space.
mirror.value = currentValue.endsWith("\n") ? `${currentValue} ` : currentValue;
const {
minHeight: currentMinHeight,
maxHeight: currentMaxHeight,
onHeight: currentOnHeight,
} = paramsRef.current;
// Trailing newline is collapsed by textarea measurement — pad with a space.
mirror.value = text.endsWith("\n") ? `${text} ` : text;
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
currentOnHeight(next);
}, [textareaRef]);
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
currentOnHeight(next);
},
[textareaRef],
);
useLayoutEffect(() => {
measure();
measure(value);
}, [maxHeight, minHeight, value, measure]);
useEffect(() => {
@@ -109,9 +111,11 @@ export function useComposerHeightMirror({
const nextWidth = source.clientWidth;
if (Math.abs(nextWidth - previousWidth) < 1) return;
previousWidth = nextWidth;
measure();
measure(paramsRef.current.value);
});
observer.observe(source);
return () => observer.disconnect();
}, [textareaRef, measure]);
return measure;
}
+3 -2
View File
@@ -1586,7 +1586,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
setBoundedInputHeight(inputHeightRef.current);
}, [setBoundedInputHeight]);
useComposerHeightMirror({
const measureComposerHeight = useComposerHeightMirror({
value,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
@@ -1689,10 +1689,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback(
(nextValue: string) => {
valueRef.current = nextValue;
measureComposerHeight(nextValue);
updateLiveTextPresence(nextValue);
onChangeText(nextValue);
},
[onChangeText, updateLiveTextPresence],
[measureComposerHeight, onChangeText, updateLiveTextPresence],
);
const handleInputFocus = useCallback(() => {
+13 -39
View File
@@ -26,6 +26,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Alert as InlineAlert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import {
ProfileDraft,
@@ -170,45 +171,30 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
const activeConnection = snapshot?.activeConnection ?? null;
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
let statusColor: string;
let statusVariant: StatusBadgeVariant = "muted";
let statusDotColor = theme.colors.foregroundMuted;
if (statusTone === "success") {
statusColor = theme.colors.palette.green[400];
statusVariant = "success";
statusDotColor = theme.colors.statusDotSuccess;
} else if (statusTone === "warning") {
statusColor = theme.colors.palette.amber[500];
statusVariant = "warning";
statusDotColor = theme.colors.statusDotWarning;
} else if (statusTone === "error") {
statusColor = theme.colors.destructive;
} else {
statusColor = theme.colors.foregroundMuted;
}
let statusPillBg: string;
if (statusTone === "success") {
statusPillBg = "rgba(74, 222, 128, 0.1)";
} else if (statusTone === "warning") {
statusPillBg = "rgba(245, 158, 11, 0.1)";
} else if (statusTone === "error") {
statusPillBg = "rgba(248, 113, 113, 0.1)";
} else {
statusPillBg = "rgba(161, 161, 170, 0.1)";
statusVariant = "error";
statusDotColor = theme.colors.statusDotDanger;
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const statusPillStyle = useMemo(
() => [styles.statusPill, { backgroundColor: statusPillBg }],
[statusPillBg],
);
const statusDotStyle = useMemo(
() => [styles.statusDot, { backgroundColor: statusColor }],
[statusColor],
() => [styles.statusDot, { backgroundColor: statusDotColor }],
[statusDotColor],
);
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
const statusLeading = useMemo(() => <View style={statusDotStyle} />, [statusDotStyle]);
return (
<View style={styles.identityBadges} testID="host-page-identity">
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
<StatusBadge label={statusLabel} variant={statusVariant} leading={statusLeading} />
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
@@ -1836,23 +1822,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
marginBottom: theme.spacing[6],
},
statusPill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
},
statusDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
badgePill: {
flexDirection: "row",
alignItems: "center",
@@ -117,9 +117,14 @@ describe("createMarkdownStyles", () => {
expect(styles.blockquote).toMatchObject({
backgroundColor: darkTheme.colors.surface1,
color: `${darkTheme.colors.foreground}cc`,
borderLeftColor: darkTheme.colors.surface2,
paddingTop: darkTheme.spacing[3],
paddingBottom: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
});
expect(styles.paragraph.marginBottom).toBe(darkTheme.spacing[3]);
expect(styles.text).not.toHaveProperty("color");
});
});
+3 -2
View File
@@ -30,7 +30,6 @@ export function createMarkdownStyles(theme: Theme) {
text: {
...webSelectableTextStyle,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
@@ -303,10 +302,12 @@ export function createMarkdownStyles(theme: Theme) {
blockquote: {
backgroundColor: theme.colors.surface1,
color: `${theme.colors.foreground}cc`,
borderLeftWidth: 4,
borderLeftColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
paddingTop: theme.spacing[3],
paddingBottom: 0,
marginVertical: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderTopLeftRadius: 0,