feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart

This commit is contained in:
Mohamed Boudra
2025-11-22 23:20:42 +01:00
parent 9feec81047
commit 95bacf8fc7
40 changed files with 3759 additions and 644 deletions
+363 -25
View File
@@ -4,13 +4,15 @@ import {
Pressable,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
Image,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native";
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
@@ -32,18 +34,45 @@ interface AgentInputAreaProps {
agentId: string;
}
const MIN_INPUT_HEIGHT = 40;
const MIN_INPUT_HEIGHT = 32;
const MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_WIDTH = 960;
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
// entering/exiting animations (see react-native-reanimated#8422), so guard them.
const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android";
const REALTIME_FADE_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250);
const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250);
const IS_WEB = Platform.OS === "web";
const SHOULD_DEBUG_INPUT_HEIGHT = IS_WEB;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
}
>;
type TextAreaHandle = {
scrollHeight?: number;
style?: {
height?: string;
overflowY?: string;
} & Record<string, unknown>;
};
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const { ws, sendAgentMessage, sendAgentAudio, agents, cancelAgentRun } = useSession();
const {
ws,
sendAgentMessage,
sendAgentAudio,
agents,
cancelAgentRun,
draftInputs,
setDraftInputs,
queuedMessages: queuedMessagesByAgent,
setQueuedMessages: setQueuedMessagesByAgent,
} = useSession();
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
const [userInput, setUserInput] = useState("");
@@ -54,8 +83,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const [recordingVolume, setRecordingVolume] = useState(0);
const [recordingDuration, setRecordingDuration] = useState(0);
const [transcribingRequestId, setTranscribingRequestId] = useState<string | null>(null);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
const overlayTransition = useSharedValue(0);
const { pickImages } = useImageAttachmentPicker();
@@ -65,6 +96,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
},
});
const debugInputHeight = (label: string, payload: Record<string, unknown>) => {
if (!SHOULD_DEBUG_INPUT_HEIGHT) {
return;
}
console.log(`[AgentInput][InputHeight] ${label}`, payload);
};
async function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return;
@@ -218,24 +256,108 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
};
}, []);
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
const contentHeight = event.nativeEvent.contentSize.height;
function isTextAreaLike(value: unknown): value is TextAreaHandle {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as TextAreaHandle;
return typeof candidate.scrollHeight === "number" && typeof candidate.style === "object";
}
function getWebTextArea(): TextAreaHandle | null {
if (!IS_WEB) {
return null;
}
const node = textInputRef.current;
if (!node) {
debugInputHeight("missing-ref", {});
return null;
}
if (isTextAreaLike(node)) {
debugInputHeight("using-ref", {
scrollHeight: node.scrollHeight,
inlineHeight: node.style?.height,
});
return node;
}
if (typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function") {
const native = (node as { getNativeRef?: () => unknown }).getNativeRef?.();
if (isTextAreaLike(native)) {
debugInputHeight("using-native-ref", {
scrollHeight: native.scrollHeight,
inlineHeight: native.style?.height,
});
return native;
}
}
debugInputHeight("no-textarea-found", {});
return null;
}
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) {
return false;
}
const element = getWebTextArea();
if (!element?.style || typeof element.scrollHeight !== "number") {
debugInputHeight(`${source}-missing`, {});
return false;
}
const previousHeight = element.style.height;
element.style.height = "auto";
const measuredHeight = element.scrollHeight;
element.style.height = previousHeight ?? "";
const bounded = Math.min(
MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, measuredHeight),
);
debugInputHeight(source, {
measuredHeight,
bounded,
scrollHeight: element.scrollHeight,
inlineHeight: previousHeight,
});
setBoundedInputHeight(bounded);
return true;
}
function setBoundedInputHeight(nextHeight: number) {
const boundedHeight = Math.min(
MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, contentHeight),
Math.max(MIN_INPUT_HEIGHT, nextHeight),
);
setInputHeight((currentHeight) => {
if (Math.abs(currentHeight - boundedHeight) < 1) {
return currentHeight;
}
debugInputHeight("set-state", {
nextHeight,
boundedHeight,
previousHeight: currentHeight,
});
return boundedHeight;
});
}
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
if (IS_WEB && measureWebInputHeight("web-measure")) {
return;
}
debugInputHeight("native-measure", {
height: event.nativeEvent.contentSize.height,
width: event.nativeEvent.contentSize.width,
});
setBoundedInputHeight(event.nativeEvent.contentSize.height);
}
const agent = agents.get(agentId);
const isAgentRunning = agent?.status === "running";
const hasText = userInput.trim().length > 0;
@@ -243,6 +365,73 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const hasSendableContent = hasText || hasImages;
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
const shouldShowVoiceControls = !isAgentRunning && !hasSendableContent;
const queuedMessages = queuedMessagesByAgent.get(agentId) ?? [];
const shouldHandleDesktopSubmit = IS_WEB;
useLayoutEffect(() => {
if (!IS_WEB) {
return;
}
measureWebInputHeight("layout-effect");
}, [userInput]);
function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleDesktopSubmit) {
return;
}
if (event.nativeEvent.key !== "Enter") {
return;
}
const { metaKey, ctrlKey, shiftKey } = event.nativeEvent;
if (shiftKey || metaKey || ctrlKey) {
return;
}
event.preventDefault();
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
return;
}
void handleSendMessage();
}
useEffect(() => {
if (!isAgentRunning || !ws.isConnected) {
setIsCancellingAgent(false);
}
}, [isAgentRunning, ws.isConnected]);
// Hydrate draft only when switching agents
useEffect(() => {
const draft = draftInputs.get(agentId);
if (!draft) {
setUserInput("");
setSelectedImages([]);
return;
}
setUserInput(draft.text);
setSelectedImages(draft.images);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentId]);
// Persist drafts into context with change detection to avoid render loops
useEffect(() => {
setDraftInputs((prev) => {
const existing = prev.get(agentId);
const isSameText = existing?.text === userInput;
const existingImages = existing?.images ?? [];
const isSameImages =
existingImages.length === selectedImages.length &&
existingImages.every((img, idx) => img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType);
if (isSameText && isSameImages) {
return prev;
}
const next = new Map(prev);
next.set(agentId, { text: userInput, images: selectedImages });
return next;
});
}, [agentId, userInput, selectedImages, setDraftInputs]);
const overlayAnimatedStyle = useAnimatedStyle(() => {
return {
@@ -275,15 +464,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
}
function handleCancelAgent() {
if (!agent || agent.status !== "running") {
if (!agent || agent.status !== "running" || isCancellingAgent) {
return;
}
if (!ws.isConnected) {
return;
}
setIsCancellingAgent(true);
cancelAgentRun(agentId);
}
function updateQueue(updater: (current: typeof queuedMessages) => typeof queuedMessages) {
setQueuedMessagesByAgent((prev) => {
const next = new Map(prev);
next.set(agentId, updater(prev.get(agentId) ?? []));
return next;
});
}
function handleQueueCurrentInput() {
if (!hasSendableContent) return;
const newItem = {
id: generateMessageId(),
text: userInput.trim(),
images: selectedImages.length ? selectedImages : undefined,
};
updateQueue((current) => [...current, newItem]);
setUserInput("");
setSelectedImages([]);
setInputHeight(MIN_INPUT_HEIGHT);
}
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedImages(item.images ?? []);
}
async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item || !ws.isConnected) return;
updateQueue((current) => current.filter((q) => q.id !== id));
// Cancels current agent run before sending queued prompt
handleCancelAgent();
await sendAgentMessage(agentId, item.text, item.images?.map((img) => img.uri));
}
const realtimeButton = (
<Pressable
onPress={handleRealtimePress}
@@ -320,8 +553,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
{/* Input area */}
<View style={styles.inputAreaContainer}>
{/* Regular input controls */}
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
<View style={styles.inputAreaContent}>
{/* Regular input controls */}
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
{/* Queue list */}
{queuedMessages.length > 0 && (
<View style={styles.queueContainer}>
{queuedMessages.map((item) => (
<View key={item.id} style={styles.queueItem}>
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable onPress={() => handleEditQueuedMessage(item.id)} style={styles.queueActionButton}>
<Pencil size={14} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={() => handleSendQueuedNow(item.id)}
style={[styles.queueActionButton, styles.queueSendButton]}
>
<ArrowUp size={14} color={theme.colors.background} />
</Pressable>
</View>
</View>
))}
</View>
)}
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer}>
@@ -338,6 +596,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
{/* Full-width text input */}
<TextInput
ref={textInputRef}
value={userInput}
onChangeText={setUserInput}
placeholder="Message agent..."
@@ -350,6 +609,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
/>
{/* Button row below input */}
@@ -373,18 +633,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
<View style={styles.rightButtonGroup}>
{isAgentRunning ? (
<>
{hasSendableContent && (
<Pressable
onPress={handleQueueCurrentInput}
disabled={!ws.isConnected}
accessibilityLabel="Queue message while agent is running"
accessibilityRole="button"
style={[styles.queueButton, !ws.isConnected && styles.buttonDisabled]}
>
<ArrowUp size={20} color="white" />
</Pressable>
)}
{realtimeButton}
<Pressable
onPress={handleCancelAgent}
disabled={!ws.isConnected}
accessibilityLabel="Stop agent"
disabled={!ws.isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton,
!ws.isConnected && styles.buttonDisabled,
(!ws.isConnected || isCancellingAgent) && styles.buttonDisabled,
]}
>
<Square size={18} color={theme.colors.background} fill={theme.colors.background} />
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={18} color="white" fill="white" />
)}
</Pressable>
</>
) : shouldShowSendButton ? (
@@ -420,17 +695,20 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
) : null}
</View>
</View>
</Animated.View>
</Animated.View>
</View>
{/* Voice note recording overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<VoiceNoteRecordingOverlay
volume={recordingVolume}
duration={recordingDuration}
onCancel={handleCancelRecording}
onSend={handleSendRecording}
isTranscribing={transcribingRequestId !== null}
/>
<View style={styles.inputAreaContent}>
<VoiceNoteRecordingOverlay
volume={recordingVolume}
duration={recordingDuration}
onCancel={handleCancelRecording}
onSend={handleSendRecording}
isTranscribing={transcribingRequestId !== null}
/>
</View>
</Animated.View>
</View>
</View>
@@ -451,6 +729,12 @@ const styles = StyleSheet.create((theme) => ({
inputAreaContainer: {
position: "relative",
minHeight: FOOTER_HEIGHT,
alignItems: "center",
width: "100%",
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_INPUT_WIDTH,
},
inputContainer: {
flexDirection: "column",
@@ -458,6 +742,7 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3],
gap: theme.spacing[3],
minHeight: FOOTER_HEIGHT,
width: "100%",
},
overlayContainer: {
position: "absolute",
@@ -465,6 +750,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0,
bottom: 0,
height: FOOTER_HEIGHT,
alignItems: "center",
},
imagePreviewContainer: {
flexDirection: "row",
@@ -499,6 +785,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
lineHeight: theme.fontSize.lg * 1.4,
...(IS_WEB
? {
outlineStyle: "none" as const,
outlineWidth: 0,
outlineColor: "transparent",
}
: {}),
},
buttonRow: {
flexDirection: "row",
@@ -559,7 +852,52 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
queueButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[600],
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
queueContainer: {
flexDirection: "column",
gap: theme.spacing[2],
},
queueItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
gap: theme.spacing[2],
},
queueText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
queueActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
queueActionButton: {
width: 32,
height: 32,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.muted,
},
queueSendButton: {
backgroundColor: theme.colors.palette.blue[600],
},
}));