mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user