* refactor: clear no-unnecessary-type-conversion + unbound-method (T3.b partial)
Fixes 15 files within the hard cap:
unbound-method (interface method shorthand → property function syntax):
- pane-context.tsx: PaneContextValue + PaneFocusContextValue (fixes agent-panel, browser-panel, draft-panel)
- provider-runner.ts: ProviderTurnRunner interface
- process-tree.ts: process.kill.bind(process)
no-unnecessary-type-conversion (remove redundant Boolean()/String() wraps):
- agent-timeline-store.ts, worktree-session.ts, pairing-qr.ts
- sherpa-realtime-session.ts, sherpa-stt.ts (String() → direct call chain)
- workspace-execution.ts, confirm-dialog.ts, use-archive-agent.ts
- use-agent-history.ts, left-sidebar.tsx, autocomplete.tsx, dropdown-menu.tsx
Deferred (9 errors across 7 files, all single-line Boolean(hovered) removals):
project-picker-modal.tsx, agent-list.tsx, branch-switcher.tsx,
file-explorer-pane.tsx, workspace-open-in-editor-button.tsx,
browser-pane.electron.tsx, sortable-inline-list.web.tsx
* fix(app): preserve Boolean coercion fallback in archive state lookups
readPendingState(...)[key] and (pendingQuery.data ?? {})[key] return
boolean | undefined at runtime — noUncheckedIndexedAccess is off so
TypeScript types this as boolean but the actual value can be undefined.
Add ?? false to maintain the explicit boolean return contract.
389 lines
10 KiB
TypeScript
389 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import {
|
|
ScrollView,
|
|
Text,
|
|
View,
|
|
Pressable,
|
|
type LayoutChangeEvent,
|
|
type NativeScrollEvent,
|
|
type NativeSyntheticEvent,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { File, Folder } from "lucide-react-native";
|
|
import type { Theme } from "@/styles/theme";
|
|
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
|
|
|
export interface AutocompleteOption {
|
|
id: string;
|
|
label: string;
|
|
detail?: string;
|
|
description?: string;
|
|
kind?: "command" | "file" | "directory";
|
|
}
|
|
|
|
interface AutocompleteProps {
|
|
options: readonly AutocompleteOption[];
|
|
selectedIndex: number;
|
|
onSelect: (option: AutocompleteOption) => void;
|
|
isLoading?: boolean;
|
|
errorMessage?: string;
|
|
loadingText?: string;
|
|
emptyText?: string;
|
|
maxHeight?: number;
|
|
}
|
|
|
|
const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu;
|
|
|
|
function removeBoltGlyphs(value?: string): string | undefined {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
const cleaned = value.replace(BOLT_GLYPH_PATTERN, "").trim();
|
|
return cleaned.length > 0 ? cleaned : undefined;
|
|
}
|
|
|
|
interface AutocompleteRowProps {
|
|
index: number;
|
|
option: AutocompleteOption;
|
|
isSelected: boolean;
|
|
mutedColor: string;
|
|
onSelect: (option: AutocompleteOption) => void;
|
|
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
|
|
}
|
|
|
|
function AutocompleteRow({
|
|
index,
|
|
option,
|
|
isSelected,
|
|
mutedColor,
|
|
onSelect,
|
|
onRowLayout,
|
|
}: AutocompleteRowProps) {
|
|
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
|
const optionDescription = removeBoltGlyphs(option.description);
|
|
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
|
|
|
const handleLayout = useCallback(
|
|
(event: LayoutChangeEvent) => onRowLayout(index, event),
|
|
[index, onRowLayout],
|
|
);
|
|
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
|
|
const pressableStyle = useCallback(
|
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.item,
|
|
(hovered || pressed || isSelected) && styles.itemActive,
|
|
],
|
|
[isSelected],
|
|
);
|
|
|
|
return (
|
|
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
|
|
{isFileOrDir ? (
|
|
<>
|
|
<View style={styles.itemLeading}>
|
|
{option.kind === "directory" ? (
|
|
<Folder size={14} color={mutedColor} />
|
|
) : (
|
|
<File size={14} color={mutedColor} />
|
|
)}
|
|
</View>
|
|
<View style={styles.itemMain}>
|
|
<View style={styles.itemHeader}>
|
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
|
{removeBoltGlyphs(option.detail) ? (
|
|
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
|
) : null}
|
|
</View>
|
|
{optionDescription ? (
|
|
<Text style={styles.itemDescription} numberOfLines={1}>
|
|
{optionDescription}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</>
|
|
) : (
|
|
<View style={styles.itemMainRow}>
|
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
|
{optionDescription ? (
|
|
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
|
{optionDescription}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function Autocomplete({
|
|
options,
|
|
selectedIndex,
|
|
onSelect,
|
|
isLoading = false,
|
|
errorMessage,
|
|
loadingText = "Loading...",
|
|
emptyText = "No results found",
|
|
maxHeight = 220,
|
|
}: AutocompleteProps) {
|
|
const { theme } = useUnistyles();
|
|
const scrollRef = useRef<ScrollView>(null);
|
|
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
|
const viewportHeightRef = useRef(0);
|
|
const scrollOffsetRef = useRef(0);
|
|
|
|
const ensureActiveItemVisible = useCallback(() => {
|
|
if (selectedIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const layout = rowLayoutsRef.current.get(selectedIndex);
|
|
if (!layout) {
|
|
return;
|
|
}
|
|
|
|
const nextOffset = getAutocompleteScrollOffset({
|
|
currentOffset: scrollOffsetRef.current,
|
|
viewportHeight: viewportHeightRef.current,
|
|
itemTop: layout.top,
|
|
itemHeight: layout.height,
|
|
});
|
|
|
|
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
|
return;
|
|
}
|
|
|
|
scrollOffsetRef.current = nextOffset;
|
|
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
}, [selectedIndex]);
|
|
|
|
const pinToBottom = useCallback(() => {
|
|
scrollRef.current?.scrollToEnd({ animated: false });
|
|
requestAnimationFrame(() => {
|
|
scrollRef.current?.scrollToEnd({ animated: false });
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
rowLayoutsRef.current.clear();
|
|
scrollOffsetRef.current = 0;
|
|
}, [options]);
|
|
|
|
useEffect(() => {
|
|
if (options.length === 0) {
|
|
return;
|
|
}
|
|
pinToBottom();
|
|
}, [options, pinToBottom]);
|
|
|
|
useEffect(() => {
|
|
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
};
|
|
}, [ensureActiveItemVisible, options.length]);
|
|
|
|
const handleScrollViewLayout = useCallback(
|
|
(event: LayoutChangeEvent) => {
|
|
viewportHeightRef.current = event.nativeEvent.layout.height;
|
|
ensureActiveItemVisible();
|
|
},
|
|
[ensureActiveItemVisible],
|
|
);
|
|
|
|
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
|
}, []);
|
|
|
|
const handleRowLayout = useCallback(
|
|
(index: number, event: LayoutChangeEvent) => {
|
|
rowLayoutsRef.current.set(index, {
|
|
top: event.nativeEvent.layout.y,
|
|
height: event.nativeEvent.layout.height,
|
|
});
|
|
ensureActiveItemVisible();
|
|
},
|
|
[ensureActiveItemVisible],
|
|
);
|
|
|
|
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={containerStyle}>
|
|
<View style={styles.emptyItem}>
|
|
<Text style={styles.emptyText}>{loadingText}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (errorMessage) {
|
|
return (
|
|
<View style={containerStyle}>
|
|
<View style={styles.emptyItem}>
|
|
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (options.length === 0) {
|
|
return (
|
|
<View style={containerStyle}>
|
|
<View style={styles.emptyItem}>
|
|
<Text style={styles.emptyText}>{emptyText}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const selectedOption = options[selectedIndex];
|
|
|
|
return (
|
|
<View style={styles.outerWrapper}>
|
|
{selectedOption?.kind === "command" && selectedOption.description ? (
|
|
<View style={styles.detailCard}>
|
|
<Text style={styles.detailLabel}>
|
|
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
|
</Text>
|
|
<Text style={styles.detailDescription}>
|
|
{removeBoltGlyphs(selectedOption.description)}
|
|
</Text>
|
|
{selectedOption.detail ? (
|
|
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
<View style={containerStyle}>
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
onLayout={handleScrollViewLayout}
|
|
onContentSizeChange={pinToBottom}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={16}
|
|
style={styles.scrollView}
|
|
contentContainerStyle={styles.scrollContent}
|
|
keyboardShouldPersistTaps="always"
|
|
>
|
|
{options.map((option, index) => (
|
|
<AutocompleteRow
|
|
key={option.id}
|
|
index={index}
|
|
option={option}
|
|
isSelected={index === selectedIndex}
|
|
mutedColor={theme.colors.foregroundMuted}
|
|
onSelect={onSelect}
|
|
onRowLayout={handleRowLayout}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme: Theme) => ({
|
|
outerWrapper: {
|
|
gap: theme.spacing[1],
|
|
},
|
|
detailCard: {
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
borderRadius: theme.borderRadius.lg,
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[3],
|
|
...theme.shadow.md,
|
|
},
|
|
detailLabel: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
detailDescription: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
detailHint: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
container: {
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
borderRadius: theme.borderRadius.lg,
|
|
overflow: "hidden",
|
|
...theme.shadow.md,
|
|
},
|
|
scrollView: {
|
|
flexGrow: 0,
|
|
flexShrink: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
item: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
minHeight: 36,
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
},
|
|
itemLeading: {
|
|
width: 18,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginRight: theme.spacing[1],
|
|
},
|
|
itemActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
itemMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
itemMainRow: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
itemHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
itemLabel: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
itemDetail: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
itemDescription: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: 2,
|
|
},
|
|
itemDescriptionInline: {
|
|
flex: 1,
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
emptyItem: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[3],
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
})) as unknown as Record<string, object>;
|