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
parent 086dde30cc
commit c5446e808d
4 changed files with 205 additions and 131 deletions

View File

@@ -1,5 +1,13 @@
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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
@@ -28,8 +36,10 @@ export default function AgentScreen() {
initializeAgent,
} = useSession();
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
const [menuContentHeight, setMenuContentHeight] = useState(0);
const menuButtonRef = useRef<View>(null);
// Keyboard animation
@@ -97,25 +107,75 @@ export default function AgentScreen() {
};
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
const handleOpenMenu = useCallback(() => {
menuButtonRef.current?.measureInWindow((x, y, width, height) => {
const screenWidth = Dimensions.get("window").width;
const verticalOffset = 6;
const horizontalMargin = 16;
const desiredLeft = x + width - DROPDOWN_WIDTH;
const maxLeft = screenWidth - DROPDOWN_WIDTH - horizontalMargin;
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
const recalculateMenuPosition = useCallback(
(onMeasured?: () => void) => {
requestAnimationFrame(() => {
const anchor = menuButtonRef.current;
setMenuPosition({
top: y + height + verticalOffset,
left: clampedLeft,
if (!anchor) {
if (onMeasured) {
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);
});
}, []);
}, [recalculateMenuPosition]);
const handleCloseMenu = useCallback(() => {
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(() => {
@@ -191,6 +251,7 @@ export default function AgentScreen() {
width: DROPDOWN_WIDTH,
},
]}
onLayout={handleMenuLayout}
>
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
<GitBranch size={20} color={theme.colors.foreground} />

View File

@@ -134,18 +134,28 @@ export default function GitDiffScreen() {
>
<View style={styles.diffLinesContainer}>
{file.lines.map((line, lineIndex) => (
<Text
<View
key={lineIndex}
style={[
styles.diffLine,
line.type === "add" && styles.addLine,
line.type === "remove" && styles.removeLine,
line.type === "header" && styles.headerLine,
line.type === "context" && styles.contextLine,
styles.diffLineContainer,
line.type === "add" && styles.addLineContainer,
line.type === "remove" && styles.removeLineContainer,
line.type === "header" && styles.headerLineContainer,
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>
</ScrollView>
@@ -232,27 +242,42 @@ const styles = StyleSheet.create((theme) => ({
diffLinesContainer: {
alignSelf: "flex-start",
},
diffLine: {
fontSize: theme.fontSize.xs,
fontFamily: "monospace",
diffLineContainer: {
minWidth: "100%",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
flexShrink: 0,
minWidth: "100%",
alignSelf: "flex-start",
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],
},
addLineText: {
color: theme.colors.palette.green[200],
},
removeLine: {
removeLineContainer: {
backgroundColor: theme.colors.palette.red[900],
},
removeLineText: {
color: theme.colors.palette.red[200],
},
headerLine: {
color: theme.colors.mutedForeground,
headerLineContainer: {
backgroundColor: theme.colors.muted,
},
contextLine: {
headerLineText: {
color: theme.colors.mutedForeground,
},
contextLineContainer: {
backgroundColor: theme.colors.card,
},
contextLineText: {
color: theme.colors.mutedForeground,
},
}));

View File

@@ -261,11 +261,6 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
return (
<View style={styles.container}>
{/* Status bar */}
<View style={styles.statusBarContainer}>
<AgentStatusBar agentId={agentId} />
</View>
{/* Border separator */}
<View style={styles.borderSeparator} />
@@ -298,10 +293,26 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
</View>
)}
{/* Input row */}
<View style={styles.inputRow}>
{/* Attachment button */}
{!isRecording && (
{/* Full-width text input */}
<TextInput
value={userInput}
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
onPress={handlePickImage}
disabled={!ws.isConnected}
@@ -312,71 +323,52 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
)}
<AgentStatusBar agentId={agentId} />
</View>
{/* Text input */}
<TextInput
value={userInput}
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}
/>
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText || hasImages ? (
// Send button when text is entered or images are selected
{/* Right button group */}
<View style={styles.rightButtonGroup}>
{hasText || hasImages ? (
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
) : !isRealtimeMode ? (
<>
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
onPress={handleVoicePress}
disabled={!ws.isConnected}
style={[
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
styles.voiceButton,
!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>
) : !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
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
) : null}
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
) : null}
</View>
</View>
</Animated.View>
@@ -400,9 +392,6 @@ const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "column",
},
statusBarContainer: {
backgroundColor: theme.colors.background,
},
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,
@@ -417,8 +406,8 @@ const styles = StyleSheet.create((theme) => ({
inputContainer: {
flexDirection: "column",
paddingHorizontal: theme.spacing[4],
paddingVertical: BASE_VERTICAL_PADDING,
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
gap: theme.spacing[3],
minHeight: FOOTER_HEIGHT,
},
overlayContainer: {
@@ -452,7 +441,26 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "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",
alignItems: "center",
gap: theme.spacing[2],
@@ -463,20 +471,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "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: {
width: 40,
height: 40,

View File

@@ -78,16 +78,13 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
</Pressable>
)}
{/* Agent Status Indicator */}
<View style={styles.statusIndicator}>
<View
style={[
styles.statusDot,
{ backgroundColor: getStatusColor(agent.status) },
]}
/>
<Text style={styles.statusText}>{getStatusLabel(agent.status)}</Text>
</View>
{/* Agent Status Indicator - just a dot */}
<View
style={[
styles.statusDot,
{ backgroundColor: getStatusColor(agent.status) },
]}
/>
{/* Mode selector modal */}
<ModeSelectorModal
@@ -105,9 +102,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
modeBadge: {
flexDirection: "row",