diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 683a5e7da..dd8139171 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -69,6 +69,7 @@ export default function RootLayout() {
+
diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx
index 23e866ac1..f8a34f253 100644
--- a/packages/app/src/app/agent/[id].tsx
+++ b/packages/app/src/app/agent/[id].tsx
@@ -1,19 +1,23 @@
-import { useEffect, useMemo } from "react";
-import { View, Text, ActivityIndicator } from "react-native";
-import { useLocalSearchParams } from "expo-router";
+import { useEffect, useMemo, useRef, useCallback, useState } from "react";
+import { View, Text, ActivityIndicator, Pressable, Modal, Dimensions } from "react-native";
+import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { MoreVertical, GitBranch } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { useSession } from "@/contexts/session-context";
import { useFooterControls } from "@/contexts/footer-controls-context";
+const DROPDOWN_WIDTH = 220;
+
export default function AgentScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
+ const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const {
agents,
@@ -24,6 +28,9 @@ export default function AgentScreen() {
initializeAgent,
} = useSession();
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
+ const [menuVisible, setMenuVisible] = useState(false);
+ const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
+ const menuButtonRef = useRef(null);
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -90,6 +97,34 @@ 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);
+
+ setMenuPosition({
+ top: y + height + verticalOffset,
+ left: clampedLeft,
+ });
+ setMenuVisible(true);
+ });
+ }, []);
+
+ const handleCloseMenu = useCallback(() => {
+ setMenuVisible(false);
+ }, []);
+
+ const handleViewChanges = useCallback(() => {
+ handleCloseMenu();
+ if (id) {
+ router.push(`/git-diff?agentId=${id}`);
+ }
+ }, [id, router, handleCloseMenu]);
+
if (!agent) {
return (
@@ -104,7 +139,16 @@ export default function AgentScreen() {
return (
{/* Header */}
-
+
+
+
+
+
+ }
+ />
{/* Content Area with Keyboard Animation */}
@@ -127,6 +171,34 @@ export default function AgentScreen() {
)}
+
+ {/* Dropdown Menu */}
+
+
+
+
+
+
+ View Changes
+
+
+
+
);
}
@@ -162,4 +234,37 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
color: theme.colors.mutedForeground,
},
+ menuButton: {
+ padding: theme.spacing[3],
+ borderRadius: theme.borderRadius.lg,
+ },
+ menuOverlay: {
+ flex: 1,
+ },
+ menuBackdrop: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ dropdownMenu: {
+ backgroundColor: theme.colors.card,
+ borderRadius: theme.borderRadius.lg,
+ padding: theme.spacing[2],
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 8,
+ elevation: 5,
+ },
+ menuItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[3],
+ paddingVertical: theme.spacing[3],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ },
+ menuItemText: {
+ fontSize: theme.fontSize.base,
+ color: theme.colors.foreground,
+ fontWeight: theme.fontWeight.normal,
+ },
}));
diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx
new file mode 100644
index 000000000..e60ae139f
--- /dev/null
+++ b/packages/app/src/app/git-diff.tsx
@@ -0,0 +1,258 @@
+import { useEffect, useState } from "react";
+import { View, Text, ScrollView, ActivityIndicator } from "react-native";
+import { useLocalSearchParams } from "expo-router";
+import { StyleSheet } from "react-native-unistyles";
+import { BackHeader } from "@/components/headers/back-header";
+import { useSession } from "@/contexts/session-context";
+
+interface ParsedDiffFile {
+ path: string;
+ lines: Array<{
+ type: "add" | "remove" | "context" | "header";
+ content: string;
+ }>;
+}
+
+function parseDiff(diffText: string): ParsedDiffFile[] {
+ if (!diffText || diffText.trim().length === 0) {
+ return [];
+ }
+
+ const files: ParsedDiffFile[] = [];
+ const sections = diffText.split(/^diff --git /m).filter(Boolean);
+
+ for (const section of sections) {
+ const lines = section.split("\n");
+ const firstLine = lines[0];
+
+ const pathMatch = firstLine.match(/a\/(.*?) b\//);
+ const path = pathMatch ? pathMatch[1] : "unknown";
+
+ const parsedLines: ParsedDiffFile["lines"] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("index ")) {
+ parsedLines.push({ type: "header", content: line });
+ } else if (line.startsWith("+")) {
+ parsedLines.push({ type: "add", content: line });
+ } else if (line.startsWith("-")) {
+ parsedLines.push({ type: "remove", content: line });
+ } else {
+ parsedLines.push({ type: "context", content: line });
+ }
+ }
+
+ files.push({ path, lines: parsedLines });
+ }
+
+ return files;
+}
+
+export default function GitDiffScreen() {
+ const { agentId } = useLocalSearchParams<{ agentId: string }>();
+ const { agents, gitDiffs, requestGitDiff } = useSession();
+ const [isLoading, setIsLoading] = useState(true);
+
+ const agent = agentId ? agents.get(agentId) : undefined;
+ const diffText = agentId ? gitDiffs.get(agentId) : undefined;
+
+ useEffect(() => {
+ if (!agentId) {
+ setIsLoading(false);
+ return;
+ }
+
+ if (diffText !== undefined) {
+ setIsLoading(false);
+ return;
+ }
+
+ requestGitDiff(agentId);
+
+ const timeout = setTimeout(() => {
+ setIsLoading(false);
+ }, 5000);
+
+ return () => clearTimeout(timeout);
+ }, [agentId, diffText, requestGitDiff]);
+
+ useEffect(() => {
+ if (diffText !== undefined) {
+ setIsLoading(false);
+ }
+ }, [diffText]);
+
+ if (!agent) {
+ return (
+
+
+
+ Agent not found
+
+
+ );
+ }
+
+ const isError = diffText?.startsWith("Error:");
+ const parsedFiles = isError || !diffText ? [] : parseDiff(diffText);
+ const hasChanges = parsedFiles.length > 0;
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+
+ Loading changes...
+
+ ) : isError ? (
+
+ {diffText}
+
+ ) : !hasChanges ? (
+
+ No changes
+
+ ) : (
+ parsedFiles.map((file, fileIndex) => (
+
+
+ {file.path}
+
+
+
+
+ {file.lines.map((line, lineIndex) => (
+
+ {line.content}
+
+ ))}
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.background,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ contentContainer: {
+ padding: theme.spacing[4],
+ },
+ loadingContainer: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: theme.spacing[16],
+ gap: theme.spacing[4],
+ },
+ loadingText: {
+ fontSize: theme.fontSize.base,
+ color: theme.colors.mutedForeground,
+ },
+ errorContainer: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: theme.spacing[16],
+ paddingHorizontal: theme.spacing[6],
+ },
+ errorText: {
+ fontSize: theme.fontSize.base,
+ color: theme.colors.destructive,
+ textAlign: "center",
+ },
+ emptyContainer: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: theme.spacing[16],
+ },
+ emptyText: {
+ fontSize: theme.fontSize.lg,
+ color: theme.colors.mutedForeground,
+ },
+ fileSection: {
+ marginBottom: theme.spacing[6],
+ borderRadius: theme.borderRadius.lg,
+ overflow: "hidden",
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ },
+ fileHeader: {
+ backgroundColor: theme.colors.muted,
+ padding: theme.spacing[3],
+ borderBottomWidth: theme.borderWidth[1],
+ borderBottomColor: theme.colors.border,
+ },
+ filePath: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.semibold,
+ color: theme.colors.foreground,
+ fontFamily: "monospace",
+ },
+ diffContent: {
+ backgroundColor: theme.colors.card,
+ },
+ diffScrollContent: {
+ flexDirection: "column",
+ alignItems: "flex-start",
+ paddingBottom: theme.spacing[2],
+ },
+ diffLinesContainer: {
+ alignSelf: "flex-start",
+ },
+ diffLine: {
+ fontSize: theme.fontSize.xs,
+ fontFamily: "monospace",
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[1],
+ flexShrink: 0,
+ minWidth: "100%",
+ },
+ addLine: {
+ backgroundColor: theme.colors.palette.green[900],
+ color: theme.colors.palette.green[200],
+ },
+ removeLine: {
+ backgroundColor: theme.colors.palette.red[900],
+ color: theme.colors.palette.red[200],
+ },
+ headerLine: {
+ color: theme.colors.mutedForeground,
+ backgroundColor: theme.colors.muted,
+ },
+ contextLine: {
+ color: theme.colors.mutedForeground,
+ },
+}));
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 7ae78886b..ab49013b9 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -267,28 +267,24 @@ export function AgentStreamView({
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
-
-
-
- Start chatting with this agent...
-
-
+
+
+ Start chatting with this agent...
+
}
ListHeaderComponent={
-
- {pendingPermissionItems.length > 0 ? (
-
- {pendingPermissionItems.map((permission) => (
-
- ))}
-
- ) : null}
-
+ pendingPermissionItems.length > 0 ? (
+
+ {pendingPermissionItems.map((permission) => (
+
+ ))}
+
+ ) : null
}
extraData={pendingPermissionItems.length}
maintainVisibleContentPosition={{
diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx
index 38160ff5f..9817e90bc 100644
--- a/packages/app/src/components/headers/back-header.tsx
+++ b/packages/app/src/components/headers/back-header.tsx
@@ -6,9 +6,10 @@ import { ArrowLeft } from "lucide-react-native";
interface BackHeaderProps {
title?: string;
+ rightContent?: React.ReactNode;
}
-export function BackHeader({ title }: BackHeaderProps) {
+export function BackHeader({ title, rightContent }: BackHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -31,8 +32,10 @@ export function BackHeader({ title }: BackHeaderProps) {
)}
- {/* Right side - Empty for now */}
-
+ {/* Right side */}
+
+ {rightContent}
+
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 0a765f84c..59aa0bd97 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -115,6 +115,10 @@ interface SessionContextValue {
pendingPermissions: Map;
setPendingPermissions: (perms: Map | ((prev: Map) => Map)) => void;
+ // Git diffs
+ gitDiffs: Map;
+ requestGitDiff: (agentId: string) => void;
+
// Helpers
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise;
@@ -167,6 +171,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const [commands, setCommands] = useState