mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
chore(lint): memoize inline styles and objects in stream/status panes
Clears 10 react-perf/jsx-no-new warnings across welcome-screen, file-explorer-pane, terminal-pane, agent-stream-view, and agent-status-bar by extracting per-item subcomponents, hoisting constant style tuples, and memoizing derived arrays/objects.
This commit is contained in:
@@ -456,10 +456,14 @@ function ControlledStatusBar({
|
||||
[canSelectMode, disabled],
|
||||
);
|
||||
|
||||
const sheetSelectStyle = useMemo(
|
||||
() => [styles.sheetSelect, modelDisabled && styles.disabledSheetSelect],
|
||||
[modelDisabled],
|
||||
);
|
||||
const renderSheetModelTrigger = useCallback(
|
||||
({ selectedModelLabel }: { selectedModelLabel: string }) => (
|
||||
<View
|
||||
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
|
||||
style={sheetSelectStyle}
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
@@ -470,7 +474,7 @@ function ControlledStatusBar({
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
),
|
||||
[ProviderIcon, modelDisabled, theme.colors.foregroundMuted, theme.iconSize.md],
|
||||
[ProviderIcon, sheetSelectStyle, theme.colors.foregroundMuted, theme.iconSize.md],
|
||||
);
|
||||
|
||||
if (!hasAnyControl) {
|
||||
@@ -1353,6 +1357,15 @@ export function DraftAgentStatusBar({
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
const hasSelectedProvider = selectedProvider !== null;
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(
|
||||
() =>
|
||||
models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
})),
|
||||
[models],
|
||||
);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
void updatePreferences((current) =>
|
||||
@@ -1400,11 +1413,6 @@ export function DraftAgentStatusBar({
|
||||
);
|
||||
}
|
||||
|
||||
const modelOptions: StatusOption[] = models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider ?? ""}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
View,
|
||||
@@ -535,7 +536,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(nextItem === undefined && agent.status !== "running"));
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
|
||||
<StreamItemWrapper gapBelow={gapBelow}>
|
||||
{content}
|
||||
{isEndOfAssistantTurn ? (
|
||||
<TurnCopyButtonSlot
|
||||
@@ -544,7 +545,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
startIndex={index}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
},
|
||||
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
|
||||
@@ -916,6 +917,15 @@ function PermissionRequestCard({
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const description = request.description ?? "";
|
||||
const resolvedToolCallDetail = useMemo(
|
||||
() =>
|
||||
request.detail ?? {
|
||||
type: "unknown" as const,
|
||||
input: request.input ?? null,
|
||||
output: null,
|
||||
},
|
||||
[request.detail, request.input],
|
||||
);
|
||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||
if (request.kind === "question") {
|
||||
return [];
|
||||
@@ -1121,13 +1131,7 @@ function PermissionRequestCard({
|
||||
|
||||
{!isPlanRequest ? (
|
||||
<ToolCallDetailsContent
|
||||
detail={
|
||||
request.detail ?? {
|
||||
type: "unknown",
|
||||
input: request.input ?? null,
|
||||
output: null,
|
||||
}
|
||||
}
|
||||
detail={resolvedToolCallDetail}
|
||||
maxHeight={200}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1317,3 +1321,16 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
gapBelow: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
);
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -146,29 +146,21 @@ function TreeRowItem({
|
||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||
{depth > 0 &&
|
||||
Array.from({ length: depth }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.indentGuide,
|
||||
{
|
||||
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<IndentGuide key={i} index={i} />
|
||||
))}
|
||||
<View style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{isDirectory ? (
|
||||
loading ? (
|
||||
<ActivityIndicator size="small" />
|
||||
) : (
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return (
|
||||
<View style={chevronStyle}>
|
||||
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
{entry.name}
|
||||
@@ -540,77 +532,84 @@ export function FileExplorerPane({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<View style={styles.errorActions}>
|
||||
{showBackFromError ? (
|
||||
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
||||
<Text style={styles.retryButtonText}>Back</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.retryButton} onPress={handleRetry}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
let paneContent: ReactNode;
|
||||
if (error) {
|
||||
paneContent = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<View style={styles.errorActions}>
|
||||
{showBackFromError ? (
|
||||
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
||||
<Text style={styles.retryButtonText}>Back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
<Pressable style={styles.retryButton} onPress={handleRetry}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : showInitialLoading ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading files…</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (showInitialLoading) {
|
||||
paneContent = (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading files…</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (treeRows.length === 0) {
|
||||
paneContent = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No files</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
paneContent = (
|
||||
<View style={TREE_PANE_CONTAINER_STYLE}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyle}>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : treeRows.length === 0 ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No files</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyle}>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={treeRowKeyExtractor}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={treeRowKeyExtractor}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={styles.container}>{paneContent}</View>;
|
||||
}
|
||||
|
||||
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
||||
@@ -959,3 +958,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
|
||||
|
||||
interface IndentGuideProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
function IndentGuide({ index }: IndentGuideProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const guideStyle = useMemo(
|
||||
() => [
|
||||
styles.indentGuide,
|
||||
{ left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 },
|
||||
],
|
||||
[index, theme.spacing],
|
||||
);
|
||||
return <View style={guideStyle} />;
|
||||
}
|
||||
|
||||
@@ -106,11 +106,13 @@ function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) {
|
||||
],
|
||||
[active],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [styles.keyButtonText, active && styles.keyButtonTextActive],
|
||||
[active],
|
||||
);
|
||||
return (
|
||||
<Pressable testID={`terminal-key-${modifier}`} onPress={handlePress} style={pressableStyle}>
|
||||
<Text style={[styles.keyButtonText, active && styles.keyButtonTextActive]}>
|
||||
{MODIFIER_LABELS[modifier]}
|
||||
</Text>
|
||||
<Text style={textStyle}>{MODIFIER_LABELS[modifier]}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -628,16 +630,7 @@ export function TerminalPane({
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={{
|
||||
style: { flex: 1 },
|
||||
matchContents: false,
|
||||
scrollEnabled: true,
|
||||
nestedScrollEnabled: true,
|
||||
overScrollMode: "never",
|
||||
bounces: false,
|
||||
automaticallyAdjustContentInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
}}
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
@@ -787,3 +780,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const TERMINAL_EMULATOR_DOM_PROPS = {
|
||||
style: { flex: 1 },
|
||||
matchContents: false,
|
||||
scrollEnabled: true,
|
||||
nestedScrollEnabled: true,
|
||||
overScrollMode: "never" as const,
|
||||
bounces: false,
|
||||
automaticallyAdjustContentInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never" as const,
|
||||
};
|
||||
|
||||
@@ -283,27 +283,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Pressable
|
||||
key={action.key}
|
||||
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
||||
onPress={action.onPress}
|
||||
testID={action.testID}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{actions.map((action) => (
|
||||
<WelcomeActionButton key={action.key} action={action} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
@@ -334,3 +316,29 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface WelcomeActionButtonProps {
|
||||
action: WelcomeAction;
|
||||
}
|
||||
|
||||
function WelcomeActionButton({ action }: WelcomeActionButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = action.icon;
|
||||
const buttonStyle = useMemo(
|
||||
() => [styles.actionButton, action.primary ? styles.actionButtonPrimary : null],
|
||||
[action.primary],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [styles.actionText, action.primary ? styles.actionTextPrimary : null],
|
||||
[action.primary],
|
||||
);
|
||||
return (
|
||||
<Pressable style={buttonStyle} onPress={action.onPress} testID={action.testID}>
|
||||
<Icon
|
||||
size={18}
|
||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
<Text style={textStyle}>{action.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user