feat(app): add git diff viewer with dropdown menu and UI improvements

- Add git diff viewer screen to display local changes per agent
  - Parse git diff output into file sections with syntax highlighting
  - Horizontal scrolling for long diff lines (no text wrapping)
  - Request diff data on mount via new WebSocket messages

- Add dropdown menu to agent header
  - Three-dot menu button with "View Changes" option
  - Position menu below button using window-aware measurements
  - Account for safe area insets and orientation changes

- Implement server-side git operations
  - Add git_diff_request/response message types
  - Execute `git diff HEAD` in agent's working directory
  - Handle errors gracefully with proper error messages

- Update session context for git diff state
  - Add gitDiffs Map to store diff content per agent
  - Add requestGitDiff helper function
  - WebSocket handler for git_diff_response messages

- Improve agent input area layout
  - Move status bar inline with attachment button
  - Full-width text input above button row
  - Better spacing and visual hierarchy

- Simplify agent status bar to show only status dot

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-10-26 22:30:43 +01:00
co-authored by Claude
parent 086dde30cc
commit c5446e808d
4 changed files with 205 additions and 131 deletions
+74 -13
View File
@@ -1,5 +1,13 @@
import { useEffect, useMemo, useRef, useCallback, useState } from "react"; import { useEffect, useMemo, useRef, useCallback, useState } from "react";
import { View, Text, ActivityIndicator, Pressable, Modal, Dimensions } from "react-native"; import {
View,
Text,
ActivityIndicator,
Pressable,
Modal,
useWindowDimensions,
LayoutChangeEvent,
} from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
@@ -28,8 +36,10 @@ export default function AgentScreen() {
initializeAgent, initializeAgent,
} = useSession(); } = useSession();
const { registerFooterControls, unregisterFooterControls } = useFooterControls(); const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [menuVisible, setMenuVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
const [menuContentHeight, setMenuContentHeight] = useState(0);
const menuButtonRef = useRef<View>(null); const menuButtonRef = useRef<View>(null);
// Keyboard animation // Keyboard animation
@@ -97,25 +107,75 @@ export default function AgentScreen() {
}; };
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]); }, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
const handleOpenMenu = useCallback(() => { const recalculateMenuPosition = useCallback(
menuButtonRef.current?.measureInWindow((x, y, width, height) => { (onMeasured?: () => void) => {
const screenWidth = Dimensions.get("window").width; requestAnimationFrame(() => {
const verticalOffset = 6; const anchor = menuButtonRef.current;
const horizontalMargin = 16;
const desiredLeft = x + width - DROPDOWN_WIDTH;
const maxLeft = screenWidth - DROPDOWN_WIDTH - horizontalMargin;
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
setMenuPosition({ if (!anchor) {
top: y + height + verticalOffset, if (onMeasured) {
left: clampedLeft, onMeasured();
}
return;
}
anchor.measureInWindow((x, y, width, height) => {
const verticalOffset = 8;
const horizontalMargin = 16;
const desiredLeft = x + width - DROPDOWN_WIDTH;
const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin;
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
// Position menu below button - add insets.top to account for status bar
const buttonBottom = y + height + insets.top;
const top = buttonBottom + verticalOffset;
// If menu would go off screen, clamp to visible area
const bottomEdge = top + menuContentHeight;
const maxBottom = windowHeight - horizontalMargin;
const clampedTop = bottomEdge > maxBottom
? Math.max(verticalOffset, maxBottom - menuContentHeight)
: top;
console.log('[Menu] Button position:', { x, y, width, height, insetsTop: insets.top });
console.log('[Menu] Calculated position:', { buttonBottom, top, clampedTop, left: clampedLeft });
setMenuPosition({
top: clampedTop,
left: clampedLeft,
});
if (onMeasured) {
onMeasured();
}
});
}); });
},
[menuContentHeight, windowHeight, windowWidth]
);
const handleOpenMenu = useCallback(() => {
recalculateMenuPosition(() => {
setMenuVisible(true); setMenuVisible(true);
}); });
}, []); }, [recalculateMenuPosition]);
const handleCloseMenu = useCallback(() => { const handleCloseMenu = useCallback(() => {
setMenuVisible(false); setMenuVisible(false);
setMenuContentHeight(0);
}, []);
useEffect(() => {
if (!menuVisible) {
return;
}
recalculateMenuPosition();
}, [menuVisible, recalculateMenuPosition]);
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setMenuContentHeight((current) => (current === height ? current : height));
}, []); }, []);
const handleViewChanges = useCallback(() => { const handleViewChanges = useCallback(() => {
@@ -191,6 +251,7 @@ export default function AgentScreen() {
width: DROPDOWN_WIDTH, width: DROPDOWN_WIDTH,
}, },
]} ]}
onLayout={handleMenuLayout}
> >
<Pressable onPress={handleViewChanges} style={styles.menuItem}> <Pressable onPress={handleViewChanges} style={styles.menuItem}>
<GitBranch size={20} color={theme.colors.foreground} /> <GitBranch size={20} color={theme.colors.foreground} />
+43 -18
View File
@@ -134,18 +134,28 @@ export default function GitDiffScreen() {
> >
<View style={styles.diffLinesContainer}> <View style={styles.diffLinesContainer}>
{file.lines.map((line, lineIndex) => ( {file.lines.map((line, lineIndex) => (
<Text <View
key={lineIndex} key={lineIndex}
style={[ style={[
styles.diffLine, styles.diffLineContainer,
line.type === "add" && styles.addLine, line.type === "add" && styles.addLineContainer,
line.type === "remove" && styles.removeLine, line.type === "remove" && styles.removeLineContainer,
line.type === "header" && styles.headerLine, line.type === "header" && styles.headerLineContainer,
line.type === "context" && styles.contextLine, line.type === "context" && styles.contextLineContainer,
]} ]}
> >
{line.content} <Text
</Text> style={[
styles.diffLineText,
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
]}
>
{line.content}
</Text>
</View>
))} ))}
</View> </View>
</ScrollView> </ScrollView>
@@ -232,27 +242,42 @@ const styles = StyleSheet.create((theme) => ({
diffLinesContainer: { diffLinesContainer: {
alignSelf: "flex-start", alignSelf: "flex-start",
}, },
diffLine: { diffLineContainer: {
fontSize: theme.fontSize.xs, minWidth: "100%",
fontFamily: "monospace",
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
flexShrink: 0, alignSelf: "flex-start",
minWidth: "100%", flexDirection: "row",
alignItems: "center",
}, },
addLine: { diffLineText: {
fontSize: theme.fontSize.xs,
fontFamily: "monospace",
color: theme.colors.foreground,
flexShrink: 0,
},
addLineContainer: {
backgroundColor: theme.colors.palette.green[900], backgroundColor: theme.colors.palette.green[900],
},
addLineText: {
color: theme.colors.palette.green[200], color: theme.colors.palette.green[200],
}, },
removeLine: { removeLineContainer: {
backgroundColor: theme.colors.palette.red[900], backgroundColor: theme.colors.palette.red[900],
},
removeLineText: {
color: theme.colors.palette.red[200], color: theme.colors.palette.red[200],
}, },
headerLine: { headerLineContainer: {
color: theme.colors.mutedForeground,
backgroundColor: theme.colors.muted, backgroundColor: theme.colors.muted,
}, },
contextLine: { headerLineText: {
color: theme.colors.mutedForeground,
},
contextLineContainer: {
backgroundColor: theme.colors.card,
},
contextLineText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
}, },
})); }));
@@ -261,11 +261,6 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Status bar */}
<View style={styles.statusBarContainer}>
<AgentStatusBar agentId={agentId} />
</View>
{/* Border separator */} {/* Border separator */}
<View style={styles.borderSeparator} /> <View style={styles.borderSeparator} />
@@ -298,10 +293,26 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
</View> </View>
)} )}
{/* Input row */} {/* Full-width text input */}
<View style={styles.inputRow}> <TextInput
{/* Attachment button */} value={userInput}
{!isRecording && ( onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
/>
{/* Button row below input */}
<View style={styles.buttonRow}>
{/* Left button group */}
<View style={styles.leftButtonGroup}>
<Pressable <Pressable
onPress={handlePickImage} onPress={handlePickImage}
disabled={!ws.isConnected} disabled={!ws.isConnected}
@@ -312,71 +323,52 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
> >
<Paperclip size={20} color={theme.colors.foreground} /> <Paperclip size={20} color={theme.colors.foreground} />
</Pressable> </Pressable>
)} <AgentStatusBar agentId={agentId} />
</View>
{/* Text input */} {/* Right button group */}
<TextInput <View style={styles.rightButtonGroup}>
value={userInput} {hasText || hasImages ? (
onChangeText={setUserInput} <Pressable
placeholder="Message agent..." onPress={handleSendMessage}
placeholderTextColor={theme.colors.mutedForeground} disabled={!ws.isConnected || isProcessing}
style={[ style={[
styles.textInput, styles.sendButton,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT }, (!ws.isConnected || isProcessing) && styles.buttonDisabled,
]} ]}
multiline >
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT} <ArrowUp size={20} color="white" />
onContentSizeChange={handleContentSizeChange} </Pressable>
editable={!isRecording && ws.isConnected} ) : !isRealtimeMode ? (
/> <>
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText || hasImages ? (
// Send button when text is entered or images are selected
<Pressable <Pressable
onPress={handleSendMessage} onPress={handleVoicePress}
disabled={!ws.isConnected || isProcessing} disabled={!ws.isConnected}
style={[ style={[
styles.sendButton, styles.voiceButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled, !ws.isConnected && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
]} ]}
> >
<ArrowUp size={20} color="white" /> {isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable> </Pressable>
) : !isRealtimeMode ? (
// Voice and Realtime buttons when no text and not in realtime mode
<>
{/* Voice recording button */}
<Pressable
onPress={handleVoicePress}
disabled={!ws.isConnected}
style={[
styles.voiceButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
]}
>
{isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable>
{/* Realtime button */} <Pressable
<Pressable onPress={startRealtime}
onPress={startRealtime} disabled={!ws.isConnected}
disabled={!ws.isConnected} style={[
style={[ styles.realtimeButton,
styles.realtimeButton, !ws.isConnected && styles.buttonDisabled,
!ws.isConnected && styles.buttonDisabled, ]}
]} >
> <AudioLines size={20} color={theme.colors.background} />
<AudioLines size={20} color={theme.colors.background} /> </Pressable>
</Pressable> </>
</> ) : null}
) : null}
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
@@ -400,9 +392,6 @@ const styles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "column", flexDirection: "column",
}, },
statusBarContainer: {
backgroundColor: theme.colors.background,
},
borderSeparator: { borderSeparator: {
height: theme.borderWidth[1], height: theme.borderWidth[1],
backgroundColor: theme.colors.border, backgroundColor: theme.colors.border,
@@ -417,8 +406,8 @@ const styles = StyleSheet.create((theme) => ({
inputContainer: { inputContainer: {
flexDirection: "column", flexDirection: "column",
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: BASE_VERTICAL_PADDING, paddingVertical: theme.spacing[3],
gap: theme.spacing[2], gap: theme.spacing[3],
minHeight: FOOTER_HEIGHT, minHeight: FOOTER_HEIGHT,
}, },
overlayContainer: { overlayContainer: {
@@ -452,7 +441,26 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
inputRow: { textInput: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
buttonRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
leftButtonGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
rightButtonGroup: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -463,20 +471,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
textInput: {
flex: 1,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
buttonRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
sendButton: { sendButton: {
width: 40, width: 40,
height: 40, height: 40,
@@ -78,16 +78,13 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
</Pressable> </Pressable>
)} )}
{/* Agent Status Indicator */} {/* Agent Status Indicator - just a dot */}
<View style={styles.statusIndicator}> <View
<View style={[
style={[ styles.statusDot,
styles.statusDot, { backgroundColor: getStatusColor(agent.status) },
{ backgroundColor: getStatusColor(agent.status) }, ]}
]} />
/>
<Text style={styles.statusText}>{getStatusLabel(agent.status)}</Text>
</View>
{/* Mode selector modal */} {/* Mode selector modal */}
<ModeSelectorModal <ModeSelectorModal
@@ -105,9 +102,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[3], gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
}, },
modeBadge: { modeBadge: {
flexDirection: "row", flexDirection: "row",