Files
paseo/packages/app/src/screens/workspace/workspace-tab-presentation.tsx
T

271 lines
7.5 KiB
TypeScript

import { useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { Check } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SyncedLoader } from "@/components/synced-loader";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
export interface WorkspaceTabPresentation {
key: string;
kind: WorkspaceTabDescriptor["kind"];
label: string;
subtitle: string;
titleState: "ready" | "loading";
icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null;
}
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = -2;
const EMPHASIZED_STATUS_DOT_OFFSET = -3;
type WorkspaceTabPresentationResolverProps = {
tab: WorkspaceTabDescriptor;
serverId: string;
workspaceId: string;
children: (presentation: WorkspaceTabPresentation) => ReactNode;
};
type WorkspaceTabPresentationResolverInnerProps = WorkspaceTabPresentationResolverProps & {
registration: NonNullable<ReturnType<typeof getPanelRegistration>>;
};
export function WorkspaceTabPresentationResolver({
tab,
serverId,
workspaceId,
children,
}: WorkspaceTabPresentationResolverProps): ReactElement {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
return (
<WorkspaceTabPresentationResolverInner
key={tab.kind}
registration={registration}
tab={tab}
serverId={serverId}
workspaceId={workspaceId}
>
{children}
</WorkspaceTabPresentationResolverInner>
);
}
function WorkspaceTabPresentationResolverInner({
registration,
tab,
serverId,
workspaceId,
children,
}: WorkspaceTabPresentationResolverInnerProps): ReactElement {
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
});
const presentation = useMemo(
() => ({
key: tab.key,
kind: tab.kind,
label: descriptor.label,
subtitle: descriptor.subtitle,
titleState: descriptor.titleState,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
}),
[
descriptor.icon,
descriptor.label,
descriptor.statusBucket,
descriptor.subtitle,
descriptor.titleState,
tab.key,
tab.kind,
],
);
return <>{children(presentation)}</>;
}
type WorkspaceTabIconProps = {
presentation: WorkspaceTabPresentation;
active?: boolean;
size?: number;
statusDotBorderColor?: string;
};
export function WorkspaceTabIcon({
presentation,
active = false,
size = 14,
statusDotBorderColor,
}: WorkspaceTabIconProps): ReactElement {
const { theme } = useUnistyles();
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
const statusDotColor =
presentation.statusBucket === null
? null
: getStatusDotColor({
theme,
bucket: presentation.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
const shouldShowLoader = shouldRenderSyncedStatusLoader({
bucket: presentation.statusBucket,
});
const Icon = presentation.icon;
if (shouldShowLoader) {
return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
<SyncedLoader size={size - 1} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
</View>
);
}
return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
<Icon size={size} color={iconColor} />
{statusDotColor ? (
<View
style={[
styles.statusDot,
{
backgroundColor: statusDotColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
) : null}
</View>
);
}
type WorkspaceTabOptionRowProps = {
presentation: WorkspaceTabPresentation;
selected: boolean;
active: boolean;
onPress: () => void;
trailingAccessory?: ReactNode;
};
export function WorkspaceTabOptionRow({
presentation,
selected,
active,
onPress,
trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement {
const { theme } = useUnistyles();
return (
<View style={[styles.optionRow, active && styles.optionRowActive]}>
<Pressable
onPress={onPress}
style={({ hovered = false, pressed }) => [
styles.optionMainPressable,
(hovered || pressed || active) && styles.optionRowActive,
]}
>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
</Text>
</View>
</Pressable>
{selected ? (
<View style={styles.optionTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
{trailingAccessory ? (
<View style={styles.optionTrailingAccessorySlot}>{trailingAccessory}</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
agentIconWrapper: {
position: "relative",
alignItems: "center",
justifyContent: "center",
},
statusDot: {
position: "absolute",
right: DEFAULT_STATUS_DOT_OFFSET,
bottom: DEFAULT_STATUS_DOT_OFFSET,
width: DEFAULT_STATUS_DOT_SIZE,
height: DEFAULT_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
optionRow: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
borderRadius: 0,
marginHorizontal: theme.spacing[1],
marginBottom: theme.spacing[1],
},
optionMainPressable: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
optionRowActive: {
backgroundColor: theme.colors.surface1,
},
optionLeadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
optionContent: {
flex: 1,
flexShrink: 1,
},
optionLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
optionTrailingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
optionTrailingAccessorySlot: {
alignItems: "center",
justifyContent: "center",
},
}));