From c5446e808d6e359a6157e90a4f66de5a0b4e9208 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Oct 2025 22:30:43 +0100 Subject: [PATCH] feat(app): add git diff viewer with dropdown menu and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/app/src/app/agent/[id].tsx | 87 +++++++-- packages/app/src/app/git-diff.tsx | 61 +++++-- .../app/src/components/agent-input-area.tsx | 168 +++++++++--------- .../app/src/components/agent-status-bar.tsx | 20 +-- 4 files changed, 205 insertions(+), 131 deletions(-) diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index f8a34f253..3fef38b4d 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -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(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} > diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx index e60ae139f..8cc137200 100644 --- a/packages/app/src/app/git-diff.tsx +++ b/packages/app/src/app/git-diff.tsx @@ -134,18 +134,28 @@ export default function GitDiffScreen() { > {file.lines.map((line, lineIndex) => ( - - {line.content} - + + {line.content} + + ))} @@ -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, }, })); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 1f0cdb147..f73dc2630 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -261,11 +261,6 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { return ( - {/* Status bar */} - - - - {/* Border separator */} @@ -298,10 +293,26 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { )} - {/* Input row */} - - {/* Attachment button */} - {!isRecording && ( + {/* Full-width text input */} + = MAX_INPUT_HEIGHT} + onContentSizeChange={handleContentSizeChange} + editable={!isRecording && ws.isConnected} + /> + + {/* Button row below input */} + + {/* Left button group */} + - )} + + - {/* Text input */} - = MAX_INPUT_HEIGHT} - onContentSizeChange={handleContentSizeChange} - editable={!isRecording && ws.isConnected} - /> - - {/* Buttons */} - - {hasText || hasImages ? ( - // Send button when text is entered or images are selected + {/* Right button group */} + + {hasText || hasImages ? ( + + + + ) : !isRealtimeMode ? ( + <> - + {isRecording ? ( + + ) : ( + + )} - ) : !isRealtimeMode ? ( - // Voice and Realtime buttons when no text and not in realtime mode - <> - {/* Voice recording button */} - - {isRecording ? ( - - ) : ( - - )} - - {/* Realtime button */} - - - - - ) : null} + + + + + ) : null} @@ -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, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 9a7ecd4e0..df78af342 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -78,16 +78,13 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) { )} - {/* Agent Status Indicator */} - - - {getStatusLabel(agent.status)} - + {/* Agent Status Indicator - just a dot */} + {/* Mode selector modal */} ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[1], }, modeBadge: { flexDirection: "row",