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) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
const buttonIconSize = Platform.OS === 'web' ? theme.iconSize.md : theme.iconSize.lg
const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused()
@@ -701,7 +702,7 @@ export function AgentInputArea({
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={theme.iconSize.md} color="white" fill="white" />
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -731,7 +732,7 @@ export function AgentInputArea({
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={theme.iconSize.md} color={theme.colors.foreground} />
<AudioLines size={buttonIconSize} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<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 { StyleSheet, useUnistyles } from 'react-native-unistyles'
import {
Bot,
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
SlidersHorizontal,
} from 'lucide-react-native'
import { getProviderIcon } from '@/components/provider-icons'
import { CombinedModelSelector } from '@/components/combined-model-selector'
@@ -424,7 +423,8 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences"
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>
<AdaptiveModalSheet
@@ -830,15 +830,22 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: theme.fontSize.sm * 1.4,
},
prefsButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
prefsButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
sheetSection: {
gap: theme.spacing[2],
},
+202 -51
View File
@@ -25,6 +25,7 @@ import {
ListChevronsUpDown,
RefreshCcw,
Upload,
WrapText,
} from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
@@ -47,6 +48,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
buildGitActions,
@@ -153,7 +155,7 @@ interface DiffFileSectionProps {
testID?: string;
}
function DiffLineView({ line }: { line: DiffLine }) {
function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) {
return (
<View
style={[
@@ -164,6 +166,15 @@ function DiffLineView({ line }: { line: DiffLine }) {
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" ? (
<HighlightedText
tokens={line.tokens}
@@ -280,10 +291,12 @@ const DiffFileHeader = memo(function DiffFileHeader({
function DiffFileBody({
file,
wrapLines,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}) {
@@ -332,39 +345,86 @@ function DiffFileBody({
}}
testID={testID}
>
{file.status === "too_large" || file.status === "binary" ? (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
) : (
<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 }]}>
{file.hunks.map((hunk, hunkIndex) =>
hunk.lines.map((line, lineIndex) => (
<DiffLineView key={`${hunkIndex}-${lineIndex}`} line={line} />
))
)}
</View>
</ScrollView>
)}
{(() => {
if (file.status === "too_large" || file.status === "binary") {
return (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
);
}
const linesContent = (() => {
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
}
const digitCount = Math.max(1, String(maxLineNo).length);
const gutterWidth = digitCount * 8 + 12;
return file.hunks.map((hunk, hunkIndex) => {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
return hunk.lines.map((line, lineIndex) => {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
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>
);
}
@@ -390,6 +450,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
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 } =
useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
@@ -736,12 +812,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return (
<DiffFileBody
file={item.file}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded]
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines]
);
const flatKeyExtractor = useCallback(
@@ -1018,19 +1095,48 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</DropdownMenuContent>
</DropdownMenu>
{files.length > 0 ? (
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp size={14} color={theme.colors.foregroundMuted} />
) : (
<ListChevronsUpDown size={14} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
</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}
</View>
</View>
@@ -1125,13 +1231,30 @@ const styles = StyleSheet.create((theme) => ({
diffStatusIconHidden: {
opacity: 0,
},
diffStatusButtons: {
flexDirection: "row",
alignItems: "center",
gap: {
xs: theme.spacing[1],
sm: theme.spacing[1],
md: 0,
},
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: {
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,
},
actionErrorText: {
@@ -1289,10 +1412,34 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1,
},
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],
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: {
flex: 1,
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
@@ -1333,4 +1480,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
tooltipText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
}));
@@ -212,6 +212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref
) {
const { theme } = useUnistyles()
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId)
const toast = useToast()
@@ -950,7 +951,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={theme.iconSize.md} color={theme.colors.foreground} />
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -984,11 +985,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]}
>
{isDictating ? (
<Square size={theme.iconSize.md} color="white" fill="white" />
<Square size={buttonIconSize} color="white" fill="white" />
) : 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>
<TooltipContent side="top" align="center" offset={8}>
@@ -1021,7 +1022,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={theme.iconSize.md} color="white" />
<Plus size={buttonIconSize} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -1043,7 +1044,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={theme.iconSize.md} color="white" />
<ArrowUp size={buttonIconSize} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -1184,12 +1185,12 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: theme.spacing[2],
gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
},
rightButtonGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
},
attachButton: {
width: 28,