feat(app): add line numbers, wrap toggle, and icon sizing improvements

This commit is contained in:
Mohamed Boudra
2026-03-21 11:47:04 +07:00
parent f6f689d570
commit 24945a9498
4 changed files with 227 additions and 67 deletions
@@ -97,6 +97,7 @@ export function AgentInputArea({
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles() const { theme } = useUnistyles()
const buttonIconSize = Platform.OS === 'web' ? theme.iconSize.md : theme.iconSize.lg
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
@@ -701,7 +702,7 @@ export function AgentInputArea({
{isCancellingAgent ? ( {isCancellingAgent ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<Square size={theme.iconSize.md} color="white" fill="white" /> <Square size={buttonIconSize} color="white" fill="white" />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -731,7 +732,7 @@ export function AgentInputArea({
{voice?.isVoiceSwitching ? ( {voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<AudioLines size={theme.iconSize.md} color={theme.colors.foreground} /> <AudioLines size={buttonIconSize} color={theme.colors.foreground} />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -2,13 +2,12 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native' import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { import {
Bot,
Brain, Brain,
ChevronDown, ChevronDown,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
SlidersHorizontal,
} from 'lucide-react-native' } from 'lucide-react-native'
import { getProviderIcon } from '@/components/provider-icons' import { getProviderIcon } from '@/components/provider-icons'
import { CombinedModelSelector } from '@/components/combined-model-selector' import { CombinedModelSelector } from '@/components/combined-model-selector'
@@ -424,7 +423,8 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<SlidersHorizontal size={theme.iconSize.md} color={theme.colors.foreground} /> <ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
<Text style={styles.prefsButtonText} numberOfLines={1}>{displayModel}</Text>
</Pressable> </Pressable>
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -830,15 +830,22 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: theme.fontSize.sm * 1.4, lineHeight: theme.fontSize.sm * 1.4,
}, },
prefsButton: { prefsButton: {
width: 28,
height: 28, height: 28,
borderRadius: theme.borderRadius.full, flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
}, },
prefsButtonPressed: { prefsButtonPressed: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
prefsButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
sheetSection: { sheetSection: {
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
+202 -51
View File
@@ -25,6 +25,7 @@ import {
ListChevronsUpDown, ListChevronsUpDown,
RefreshCcw, RefreshCcw,
Upload, Upload,
WrapText,
} from "lucide-react-native"; } from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { import {
@@ -47,6 +48,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { import {
buildGitActions, buildGitActions,
@@ -153,7 +155,7 @@ interface DiffFileSectionProps {
testID?: string; testID?: string;
} }
function DiffLineView({ line }: { line: DiffLine }) { function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) {
return ( return (
<View <View
style={[ style={[
@@ -164,6 +166,15 @@ function DiffLineView({ line }: { line: DiffLine }) {
line.type === "context" && styles.contextLineContainer, line.type === "context" && styles.contextLineContainer,
]} ]}
> >
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}>
{lineNumber != null ? String(lineNumber) : ""}
</Text>
</View>
{line.tokens && line.type !== "header" ? ( {line.tokens && line.type !== "header" ? (
<HighlightedText <HighlightedText
tokens={line.tokens} tokens={line.tokens}
@@ -280,10 +291,12 @@ const DiffFileHeader = memo(function DiffFileHeader({
function DiffFileBody({ function DiffFileBody({
file, file,
wrapLines,
onBodyHeightChange, onBodyHeightChange,
testID, testID,
}: { }: {
file: ParsedDiffFile; file: ParsedDiffFile;
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void; onBodyHeightChange?: (path: string, height: number) => void;
testID?: string; testID?: string;
}) { }) {
@@ -332,39 +345,86 @@ function DiffFileBody({
}} }}
testID={testID} testID={testID}
> >
{file.status === "too_large" || file.status === "binary" ? ( {(() => {
<View style={styles.statusMessageContainer}> if (file.status === "too_large" || file.status === "binary") {
<Text style={styles.statusMessageText}> return (
{file.status === "binary" ? "Binary file" : "Diff too large to display"} <View style={styles.statusMessageContainer}>
</Text> <Text style={styles.statusMessageText}>
</View> {file.status === "binary" ? "Binary file" : "Diff too large to display"}
) : ( </Text>
<ScrollView </View>
ref={scrollViewRef} );
horizontal }
nestedScrollEnabled
showsHorizontalScrollIndicator const linesContent = (() => {
bounces={false} let maxLineNo = 0;
style={styles.diffContent} for (const hunk of file.hunks) {
contentContainerStyle={styles.diffContentInner} maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
onScroll={handleScroll} }
scrollEventThrottle={16} const digitCount = Math.max(1, String(maxLineNo).length);
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)} const gutterWidth = digitCount * 8 + 12;
// When at left edge, wait for close gesture to fail before scrolling. return file.hunks.map((hunk, hunkIndex) => {
// The close gesture fails quickly on leftward swipes (failOffsetX=-10), let oldLineNo = hunk.oldStart;
// so scrolling left works normally. On rightward swipes, close gesture let newLineNo = hunk.newStart;
// activates and closes the sidebar. return hunk.lines.map((line, lineIndex) => {
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} let lineNumber: number | null = null;
> if (line.type === "remove") {
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}> lineNumber = oldLineNo;
{file.hunks.map((hunk, hunkIndex) => oldLineNo++;
hunk.lines.map((line, lineIndex) => ( } else if (line.type === "add") {
<DiffLineView key={`${hunkIndex}-${lineIndex}`} line={line} /> lineNumber = newLineNo;
)) newLineNo++;
)} } else if (line.type === "context") {
</View> lineNumber = newLineNo;
</ScrollView> oldLineNo++;
)} newLineNo++;
}
return (
<DiffLineView
key={`${hunkIndex}-${lineIndex}`}
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
/>
);
});
});
})();
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{linesContent}
</View>
</View>
);
}
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={styles.diffContent}
contentContainerStyle={styles.diffContentInner}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
{linesContent}
</View>
</ScrollView>
);
})()}
</View> </View>
); );
} }
@@ -390,6 +450,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const [wrapLines, setWrapLines] = useState(false);
useEffect(() => {
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
if (value === "true") setWrapLines(true);
});
}, []);
const handleToggleWrapLines = useCallback(() => {
setWrapLines((prev) => {
const next = !prev;
AsyncStorage.setItem("diff-wrap-lines", String(next));
return next;
});
}, []);
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, cwd }); useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null; const gitStatus = status && status.isGit ? status : null;
@@ -736,12 +812,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return ( return (
<DiffFileBody <DiffFileBody
file={item.file} file={item.file}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange} onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`} testID={`diff-file-${item.fileIndex}-body`}
/> />
); );
}, },
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded] [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines]
); );
const flatKeyExtractor = useCallback( const flatKeyExtractor = useCallback(
@@ -1018,19 +1095,48 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{files.length > 0 ? ( {files.length > 0 ? (
<Pressable <View style={styles.diffStatusButtons}>
style={({ hovered, pressed }) => [ <Tooltip delayDuration={300}>
styles.expandAllButton, <TooltipTrigger asChild>
(hovered || pressed) && styles.diffStatusRowHovered, <Pressable
]} style={({ hovered, pressed }) => [
onPress={handleToggleExpandAll} styles.expandAllButton,
> (hovered || pressed) && styles.diffStatusRowHovered,
{allExpanded ? ( ]}
<ListChevronsDownUp size={14} color={theme.colors.foregroundMuted} /> onPress={handleToggleWrapLines}
) : ( >
<ListChevronsUpDown size={14} color={theme.colors.foregroundMuted} /> <WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
)} </Pressable>
</Pressable> </TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
) : null} ) : null}
</View> </View>
</View> </View>
@@ -1125,13 +1231,30 @@ const styles = StyleSheet.create((theme) => ({
diffStatusIconHidden: { diffStatusIconHidden: {
opacity: 0, opacity: 0,
}, },
diffStatusButtons: {
flexDirection: "row",
alignItems: "center",
gap: {
xs: theme.spacing[1],
sm: theme.spacing[1],
md: 0,
},
},
expandAllButton: { expandAllButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
marginVertical: theme.spacing[2], marginVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[1], paddingHorizontal: {
paddingVertical: theme.spacing[1], xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
paddingVertical: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base, borderRadius: theme.borderRadius.base,
}, },
actionErrorText: { actionErrorText: {
@@ -1289,10 +1412,34 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
diffLineContainer: { diffLineContainer: {
paddingHorizontal: theme.spacing[3], flexDirection: "row",
alignItems: "stretch",
},
lineNumberGutter: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
marginRight: theme.spacing[2],
alignSelf: "stretch",
justifyContent: "center",
},
lineNumberText: {
textAlign: "right",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
},
addLineNumberText: {
color: theme.colors.palette.green[400],
},
removeLineNumberText: {
color: theme.colors.palette.red[500],
}, },
diffLineText: { diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -1333,4 +1480,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontStyle: "italic", fontStyle: "italic",
}, },
tooltipText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
})); }));
@@ -212,6 +212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref ref
) { ) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}` const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId) markScrollInvestigationRender(investigationComponentId)
const toast = useToast() const toast = useToast()
@@ -950,7 +951,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(!isConnected || disabled) && styles.buttonDisabled, (!isConnected || disabled) && styles.buttonDisabled,
]} ]}
> >
<Paperclip size={theme.iconSize.md} color={theme.colors.foreground} /> <Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text> <Text style={styles.tooltipText}>Attach images</Text>
@@ -984,11 +985,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]} ]}
> >
{isDictating ? ( {isDictating ? (
<Square size={theme.iconSize.md} color="white" fill="white" /> <Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={theme.iconSize.md} color={theme.colors.foreground} /> <MicOff size={buttonIconSize} color={theme.colors.foreground} />
) : ( ) : (
<Mic size={theme.iconSize.md} color={theme.colors.foreground} /> <Mic size={buttonIconSize} color={theme.colors.foreground} />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -1021,7 +1022,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(!isConnected || disabled) && styles.buttonDisabled, (!isConnected || disabled) && styles.buttonDisabled,
]} ]}
> >
<Plus size={theme.iconSize.md} color="white" /> <Plus size={buttonIconSize} color="white" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
@@ -1043,7 +1044,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? ( {isSubmitLoading ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<ArrowUp size={theme.iconSize.md} color="white" /> <ArrowUp size={buttonIconSize} color="white" />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -1184,12 +1185,12 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: { leftButtonGroup: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',
gap: theme.spacing[2], gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
}, },
rightButtonGroup: { rightButtonGroup: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing[2], gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
}, },
attachButton: { attachButton: {
width: 28, width: 28,