refactor: split sidebar callout from generic Alert primitive
Rename CalloutCard to SidebarCallout (it was always sidebar-specific) and add a shadcn-style Alert primitive with info/success/warning/error variants for inline use in screens. Replace the misused SidebarCallout in project settings with Alert and surface the underlying RPC error message instead of generic copy. Tune destructive color per theme to a calm tinted red.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -365,7 +365,7 @@ function FooterIconButton({
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
icon: typeof Plus;
|
||||
icon: typeof FolderPlus;
|
||||
theme: SidebarTheme;
|
||||
}) {
|
||||
return (
|
||||
@@ -448,7 +448,7 @@ function SidebarFooter({
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
accessibilityLabel="Add project"
|
||||
icon={Plus}
|
||||
icon={FolderPlus}
|
||||
theme={theme}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
|
||||
+12
-12
@@ -55,15 +55,15 @@ vi.mock("lucide-react-native", () => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
import { CalloutCard } from "./callout-card";
|
||||
import { SidebarCallout } from "./sidebar-callout";
|
||||
|
||||
type CalloutCardActions = React.ComponentProps<typeof CalloutCard>["actions"];
|
||||
type SidebarCalloutActions = React.ComponentProps<typeof SidebarCallout>["actions"];
|
||||
|
||||
function buildSingleAction(onPress: () => void): CalloutCardActions {
|
||||
function buildSingleAction(onPress: () => void): SidebarCalloutActions {
|
||||
return [{ label: "Undo", onPress }];
|
||||
}
|
||||
|
||||
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): CalloutCardActions {
|
||||
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): SidebarCalloutActions {
|
||||
return [
|
||||
{ label: "What's new", onPress: onWhatsNew },
|
||||
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
|
||||
@@ -72,7 +72,7 @@ function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): Callout
|
||||
|
||||
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
|
||||
|
||||
describe("CalloutCard", () => {
|
||||
describe("SidebarCallout", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("CalloutCard", () => {
|
||||
it("renders title and description", () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
|
||||
<SidebarCallout title="Update available" description="v1.2.3 is ready to install." />,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("CalloutCard", () => {
|
||||
|
||||
it("renders an icon next to the title", () => {
|
||||
act(() => {
|
||||
root?.render(<CalloutCard title="Update available" icon={calloutTitleIcon} />);
|
||||
root?.render(<SidebarCallout title="Update available" icon={calloutTitleIcon} />);
|
||||
});
|
||||
|
||||
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
|
||||
@@ -116,7 +116,7 @@ describe("CalloutCard", () => {
|
||||
const onPress = vi.fn();
|
||||
const actions = buildSingleAction(onPress);
|
||||
act(() => {
|
||||
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
|
||||
root?.render(<SidebarCallout description="Saved." actions={actions} testID="callout" />);
|
||||
});
|
||||
|
||||
const button = container?.querySelector(
|
||||
@@ -130,7 +130,7 @@ describe("CalloutCard", () => {
|
||||
const actions = buildTwoActions(vi.fn(), vi.fn());
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CalloutCard
|
||||
<SidebarCallout
|
||||
title="Update available"
|
||||
description="v1 ready."
|
||||
actions={actions}
|
||||
@@ -149,7 +149,7 @@ describe("CalloutCard", () => {
|
||||
|
||||
it("renders no action row when no actions are provided", () => {
|
||||
act(() => {
|
||||
root?.render(<CalloutCard description="Copied" testID="callout" />);
|
||||
root?.render(<SidebarCallout description="Copied" testID="callout" />);
|
||||
});
|
||||
|
||||
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
|
||||
@@ -158,7 +158,7 @@ describe("CalloutCard", () => {
|
||||
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
|
||||
const onDismiss = vi.fn();
|
||||
act(() => {
|
||||
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
|
||||
root?.render(<SidebarCallout description="Saved" onDismiss={onDismiss} testID="callout" />);
|
||||
});
|
||||
|
||||
const dismissButton = container?.querySelector(
|
||||
@@ -169,7 +169,7 @@ describe("CalloutCard", () => {
|
||||
|
||||
it("omits the dismiss button when onDismiss is not provided", () => {
|
||||
act(() => {
|
||||
root?.render(<CalloutCard description="Saved" testID="callout" />);
|
||||
root?.render(<SidebarCallout description="Saved" testID="callout" />);
|
||||
});
|
||||
|
||||
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
|
||||
+19
-13
@@ -3,33 +3,33 @@ import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export type CalloutActionVariant = "primary" | "secondary";
|
||||
export type SidebarCalloutActionVariant = "primary" | "secondary";
|
||||
|
||||
export interface CalloutAction {
|
||||
export interface SidebarCalloutAction {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
variant?: CalloutActionVariant;
|
||||
variant?: SidebarCalloutActionVariant;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export type CalloutVariant = "default" | "success" | "error";
|
||||
export type SidebarCalloutVariant = "default" | "success" | "error";
|
||||
|
||||
export interface CalloutCardProps {
|
||||
export interface SidebarCalloutProps {
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: CalloutVariant;
|
||||
actions?: readonly CalloutAction[];
|
||||
variant?: SidebarCalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
onDismiss?: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
|
||||
export function SidebarCalloutDescriptionText({ children }: { children: ReactNode }) {
|
||||
return <Text style={styles.description}>{children}</Text>;
|
||||
}
|
||||
|
||||
export function CalloutCard({
|
||||
export function SidebarCallout({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
@@ -37,7 +37,7 @@ export function CalloutCard({
|
||||
actions,
|
||||
onDismiss,
|
||||
testID,
|
||||
}: CalloutCardProps) {
|
||||
}: SidebarCalloutProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const visibleActions = (actions ?? []).slice(0, 2);
|
||||
const hasHeader = title != null || icon != null;
|
||||
@@ -84,7 +84,7 @@ export function CalloutCard({
|
||||
) : null}
|
||||
|
||||
{hasDescription && typeof description === "string" ? (
|
||||
<CalloutDescriptionText>{description}</CalloutDescriptionText>
|
||||
<SidebarCalloutDescriptionText>{description}</SidebarCalloutDescriptionText>
|
||||
) : null}
|
||||
{hasDescription && typeof description !== "string" ? (
|
||||
<View style={styles.descriptionSlot}>{description}</View>
|
||||
@@ -93,7 +93,7 @@ export function CalloutCard({
|
||||
{visibleActions.length > 0 ? (
|
||||
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
|
||||
{visibleActions.map((action, index) => (
|
||||
<CalloutActionButton
|
||||
<SidebarCalloutActionButton
|
||||
key={action.label}
|
||||
action={action}
|
||||
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
|
||||
@@ -106,7 +106,13 @@ export function CalloutCard({
|
||||
);
|
||||
}
|
||||
|
||||
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
|
||||
function SidebarCalloutActionButton({
|
||||
action,
|
||||
testID,
|
||||
}: {
|
||||
action: SidebarCalloutAction;
|
||||
testID?: string;
|
||||
}) {
|
||||
const isPrimary = action.variant === "primary";
|
||||
const labelStyle = useMemo(
|
||||
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
|
||||
@@ -0,0 +1,121 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "lucide-react-native";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface AlertProps {
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
variant?: AlertVariant;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const VARIANT_ICON: Record<Exclude<AlertVariant, "default">, LucideIcon> = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
};
|
||||
|
||||
export function Alert({
|
||||
title,
|
||||
description,
|
||||
variant = "default",
|
||||
icon,
|
||||
children,
|
||||
testID,
|
||||
}: AlertProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accentColor = resolveAccentColor(variant, theme);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, accentColor ? { borderColor: accentColor } : null],
|
||||
[accentColor],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, accentColor ? { color: accentColor } : null],
|
||||
[accentColor],
|
||||
);
|
||||
|
||||
const resolvedIcon = useMemo(() => {
|
||||
if (icon !== undefined) return icon;
|
||||
if (variant === "default") return null;
|
||||
const Icon = VARIANT_ICON[variant];
|
||||
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
||||
}, [icon, variant, theme, accentColor]);
|
||||
|
||||
const hasDescription = description != null && description !== "";
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
||||
<View style={styles.body}>
|
||||
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
||||
{hasDescription && typeof description === "string" ? (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
) : null}
|
||||
{hasDescription && typeof description !== "string" ? (
|
||||
<View style={styles.descriptionSlot}>{description}</View>
|
||||
) : null}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAccentColor(
|
||||
variant: AlertVariant,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): string | null {
|
||||
if (variant === "info") return theme.colors.palette.blue[300];
|
||||
if (variant === "success") return theme.colors.palette.green[400];
|
||||
if (variant === "warning") return theme.colors.palette.amber[500];
|
||||
if (variant === "error") return theme.colors.destructive;
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
iconSlot: {
|
||||
paddingTop: 2,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
descriptionSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -9,22 +9,20 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
type CalloutAction,
|
||||
CalloutCard,
|
||||
type CalloutCardProps,
|
||||
type CalloutVariant,
|
||||
} from "@/components/callout-card";
|
||||
SidebarCallout,
|
||||
type SidebarCalloutAction,
|
||||
type SidebarCalloutProps,
|
||||
type SidebarCalloutVariant,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
export type SidebarCalloutAction = CalloutAction;
|
||||
|
||||
export interface SidebarCalloutOptions {
|
||||
id: string;
|
||||
dismissalKey?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: CalloutVariant;
|
||||
variant?: SidebarCalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
dismissible?: boolean;
|
||||
priority?: number;
|
||||
@@ -218,7 +216,7 @@ export function SidebarCalloutViewport() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cardProps: CalloutCardProps = {
|
||||
const cardProps: SidebarCalloutProps = {
|
||||
title: activeCallout.title,
|
||||
description: activeCallout.description,
|
||||
icon: activeCallout.icon,
|
||||
@@ -229,5 +227,5 @@ export function SidebarCalloutViewport() {
|
||||
testID: activeCallout.testID,
|
||||
};
|
||||
|
||||
return <CalloutCard {...cardProps} />;
|
||||
return <SidebarCallout {...cardProps} />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Gift } from "lucide-react-native";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { CalloutDescriptionText } from "@/components/callout-card";
|
||||
import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import {
|
||||
type SidebarCalloutAction,
|
||||
SidebarCalloutDescriptionText,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -167,14 +170,14 @@ export function UpdateCalloutSource() {
|
||||
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
|
||||
return (
|
||||
<>
|
||||
<CalloutDescriptionText>
|
||||
<SidebarCalloutDescriptionText>
|
||||
{versionLabel
|
||||
? `${versionLabel} is ready to install.`
|
||||
: "A new version is ready to install."}
|
||||
</CalloutDescriptionText>
|
||||
<CalloutDescriptionText>
|
||||
</SidebarCalloutDescriptionText>
|
||||
<SidebarCalloutDescriptionText>
|
||||
Upgrading the app will stop running agents and close terminal sessions.
|
||||
</CalloutDescriptionText>
|
||||
</SidebarCalloutDescriptionText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,16 +355,16 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/callout-card", () => ({
|
||||
CalloutCard: ({
|
||||
vi.mock("@/components/ui/alert", () => ({
|
||||
Alert: ({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: React.ReactNode;
|
||||
actions?: readonly { label: string; onPress: () => void }[];
|
||||
children?: React.ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
@@ -372,18 +372,7 @@ vi.mock("@/components/callout-card", () => ({
|
||||
{ "data-testid": testID, role: "alert" },
|
||||
React.createElement("span", { "data-testid": `${testID}-title` }, title),
|
||||
React.createElement("span", { "data-testid": `${testID}-description` }, description),
|
||||
actions?.map((action, index) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
key: index,
|
||||
type: "button",
|
||||
"data-testid": `${testID}-action-${index}`,
|
||||
onClick: action.onPress,
|
||||
},
|
||||
action.label,
|
||||
),
|
||||
),
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CalloutCard, type CalloutAction } from "@/components/callout-card";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -232,6 +232,7 @@ function renderContent({
|
||||
return (
|
||||
<ReadFailureCallout
|
||||
kind="transport"
|
||||
error={readQuery.error}
|
||||
onReload={onReload}
|
||||
hasMultipleHosts={hasMultipleHosts}
|
||||
/>
|
||||
@@ -242,6 +243,7 @@ function renderContent({
|
||||
return (
|
||||
<ReadFailureCallout
|
||||
kind={readError.code}
|
||||
error={null}
|
||||
onReload={onReload}
|
||||
hasMultipleHosts={hasMultipleHosts}
|
||||
/>
|
||||
@@ -277,79 +279,66 @@ function revisionToKey(revision: PaseoConfigRevision | null): string {
|
||||
|
||||
interface ReadFailureCalloutProps {
|
||||
kind: "transport" | ProjectConfigRpcError["code"];
|
||||
error: unknown;
|
||||
onReload: () => void;
|
||||
hasMultipleHosts: boolean;
|
||||
}
|
||||
|
||||
function ReadFailureCallout({ kind, onReload, hasMultipleHosts }: ReadFailureCalloutProps) {
|
||||
const reloadAction = useMemo<readonly CalloutAction[]>(
|
||||
() => [{ label: "Reload", onPress: onReload, variant: "primary" }],
|
||||
[onReload],
|
||||
);
|
||||
|
||||
if (kind === "invalid_project_config") {
|
||||
return (
|
||||
<View style={styles.errorBlock}>
|
||||
<CalloutCard
|
||||
testID="invalid-callout"
|
||||
variant="error"
|
||||
title="paseo.json couldn't be parsed"
|
||||
description="Fix the file on disk, then reload."
|
||||
actions={reloadAction}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "project_not_found") {
|
||||
return (
|
||||
<View style={styles.errorBlock}>
|
||||
<CalloutCard
|
||||
testID="project-not-found-callout"
|
||||
variant="error"
|
||||
title="This host doesn't have this project"
|
||||
description={
|
||||
hasMultipleHosts
|
||||
? "Switch to another host above, or reload."
|
||||
: "The selected host has no record of this project."
|
||||
}
|
||||
actions={reloadAction}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "transport") {
|
||||
return (
|
||||
<View style={styles.errorBlock}>
|
||||
<CalloutCard
|
||||
testID="read-transport-callout"
|
||||
variant="error"
|
||||
title="Couldn't reach this host"
|
||||
description={
|
||||
hasMultipleHosts
|
||||
? "The host didn't respond. Switch to another host above, or reload."
|
||||
: "The host didn't respond. Try again."
|
||||
}
|
||||
actions={reloadAction}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) {
|
||||
const { testID, title, description } = resolveReadFailureCopy({ kind, error, hasMultipleHosts });
|
||||
return (
|
||||
<View style={styles.errorBlock}>
|
||||
<CalloutCard
|
||||
testID="read-failed-callout"
|
||||
variant="error"
|
||||
title="Couldn't load paseo.json"
|
||||
description="Reload to try again."
|
||||
actions={reloadAction}
|
||||
/>
|
||||
<Alert testID={testID} variant="error" title={title} description={description}>
|
||||
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
|
||||
Reload
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveReadFailureCopy(input: {
|
||||
kind: ReadFailureCalloutProps["kind"];
|
||||
error: unknown;
|
||||
hasMultipleHosts: boolean;
|
||||
}): { testID: string; title: string; description: string } {
|
||||
if (input.kind === "invalid_project_config") {
|
||||
return {
|
||||
testID: "invalid-callout",
|
||||
title: "paseo.json couldn't be parsed",
|
||||
description: "Fix the file on disk, then reload.",
|
||||
};
|
||||
}
|
||||
if (input.kind === "project_not_found") {
|
||||
return {
|
||||
testID: "project-not-found-callout",
|
||||
title: "This host doesn't have this project",
|
||||
description: input.hasMultipleHosts
|
||||
? "Switch to another host above, or reload."
|
||||
: "The selected host has no record of this project.",
|
||||
};
|
||||
}
|
||||
if (input.kind === "transport") {
|
||||
const detail = errorToDetail(input.error);
|
||||
return {
|
||||
testID: "read-transport-callout",
|
||||
title: "Couldn't load paseo.json",
|
||||
description: detail ?? "The host didn't respond.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
testID: "read-failed-callout",
|
||||
title: "Couldn't load paseo.json",
|
||||
description: "Reload to try again.",
|
||||
};
|
||||
}
|
||||
|
||||
function errorToDetail(error: unknown): string | null {
|
||||
if (error instanceof Error && error.message.length > 0) return error.message;
|
||||
if (typeof error === "string" && error.length > 0) return error;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ProjectConfigFormProps {
|
||||
baseConfig: PaseoConfigRaw;
|
||||
revision: PaseoConfigRevision | null;
|
||||
@@ -508,18 +497,6 @@ function ProjectConfigForm({
|
||||
[draft.scripts],
|
||||
);
|
||||
|
||||
const staleActions = useMemo<readonly CalloutAction[]>(
|
||||
() => [{ label: "Reload", onPress: handleReload, variant: "primary" }],
|
||||
[handleReload],
|
||||
);
|
||||
const writeFailedActions = useMemo<readonly CalloutAction[]>(
|
||||
() => [
|
||||
{ label: "Try again", onPress: handleSave, variant: "primary" },
|
||||
{ label: "Reload", onPress: handleReload, variant: "secondary" },
|
||||
],
|
||||
[handleSave, handleReload],
|
||||
);
|
||||
|
||||
const scriptsTrailing = useMemo(
|
||||
() => (
|
||||
<Pressable
|
||||
@@ -594,25 +571,49 @@ function ProjectConfigForm({
|
||||
|
||||
{isStale ? (
|
||||
<View style={styles.calloutWrap}>
|
||||
<CalloutCard
|
||||
<Alert
|
||||
testID="stale-callout"
|
||||
variant="error"
|
||||
title="Config changed on disk"
|
||||
description="Reload to fetch the latest paseo.json before saving."
|
||||
actions={staleActions}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
testID="stale-callout-action-0"
|
||||
onPress={handleReload}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isWriteFailed ? (
|
||||
<View style={styles.calloutWrap}>
|
||||
<CalloutCard
|
||||
<Alert
|
||||
testID="write-failed-callout"
|
||||
variant="error"
|
||||
title="Couldn't save paseo.json"
|
||||
description="Try again, or reload the latest version from disk."
|
||||
actions={writeFailedActions}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
testID="write-failed-callout-action-0"
|
||||
onPress={handleSave}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
testID="write-failed-callout-action-1"
|
||||
onPress={handleReload}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1033,8 +1034,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
errorBlock: {
|
||||
padding: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
lifecycleInput: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -164,7 +164,7 @@ const lightSemanticColors = {
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
// Semantic
|
||||
destructive: "#dc2626",
|
||||
destructive: "#b04138", // dark warm red on white — calm but unambiguously red
|
||||
destructiveForeground: "#ffffff",
|
||||
success: "#20744A",
|
||||
successForeground: "#ffffff",
|
||||
@@ -233,6 +233,7 @@ interface DarkThemeConfig {
|
||||
borderAccent: string;
|
||||
accent: string;
|
||||
accentBright: string;
|
||||
destructive: string;
|
||||
}
|
||||
|
||||
const darkTerminalAnsi = {
|
||||
@@ -276,7 +277,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
accentBright: tint.accentBright,
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
destructive: "#ef4444",
|
||||
destructive: tint.destructive,
|
||||
destructiveForeground: "#ffffff",
|
||||
success: tint.accent,
|
||||
successForeground: "#ffffff",
|
||||
@@ -332,6 +333,7 @@ const paseoDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#2F3534",
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
destructive: "#c64f43", // warm red, hue ~7 — reads as red (not pink) against the green tint
|
||||
});
|
||||
|
||||
// Zinc — neutral gray, no tint
|
||||
@@ -350,6 +352,7 @@ const zincDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#303036",
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming
|
||||
});
|
||||
|
||||
// Midnight — subtle blue tint
|
||||
@@ -368,6 +371,7 @@ const midnightDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#2e3040",
|
||||
accent: "#3b6fcf",
|
||||
accentBright: "#7eaaeb",
|
||||
destructive: "#c44a52", // red with a hint of cool lean against the blue tint
|
||||
});
|
||||
|
||||
// Claude — warm neutral with subtle orange undertone
|
||||
@@ -386,6 +390,7 @@ const claudeDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#36332f",
|
||||
accent: "#d97757",
|
||||
accentBright: "#e89a7f",
|
||||
destructive: "#cf513e", // warm orange-red, hue ~10 — sits with the Claude orange accent
|
||||
});
|
||||
|
||||
// Ghostty — blue-tinted dark based on Ghostty default background
|
||||
@@ -404,6 +409,7 @@ const ghosttyDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#3f4454",
|
||||
accent: "#89b4fa",
|
||||
accentBright: "#b4d0fc",
|
||||
destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces
|
||||
});
|
||||
|
||||
const commonTheme = {
|
||||
|
||||
Reference in New Issue
Block a user