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],
},
}));
+4 -2
View File
@@ -18,10 +18,12 @@ export function AgentList({ agents }: AgentListProps) {
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
const isActionSheetVisible = actionAgent !== null;
// Sort agents by lastActivityAt (most recent first)
// Sort agents by the persisted timestamp of the last user message, falling back to last activity
const agentArray = useMemo(() => {
return Array.from(agents.values()).sort((a, b) => {
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime();
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
return sortB - sortA;
});
}, [agents]);
+104 -63
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { ReactNode } from "react";
import {
View,
Text,
@@ -39,6 +40,7 @@ import {
extractReadEntries,
} from "@/utils/tool-call-parsers";
const MAX_CHAT_WIDTH = 960;
export interface AgentStreamViewProps {
agentId: string;
agent: Agent;
@@ -219,30 +221,31 @@ export function AgentStreamView({
}
function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) {
let content: React.ReactNode = null;
switch (item.kind) {
case "user_message":
return (
content = (
<UserMessage
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
break;
case "assistant_message":
return (
content = (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
);
break;
case "thought":
return (
<AgentThoughtMessage
message={item.text}
/>
);
content = <AgentThoughtMessage message={item.text} />;
break;
case "tool_call": {
const { payload } = item;
@@ -250,7 +253,7 @@ export function AgentStreamView({
if (payload.source === "agent") {
const data = payload.data;
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
return (
content = (
<ToolCall
toolName={toolLabel}
kind={data.kind}
@@ -264,22 +267,23 @@ export function AgentStreamView({
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
/>
);
} else {
const data = payload.data;
content = (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
/>
);
}
const data = payload.data;
return (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
/>
);
break;
}
case "activity_log":
return (
content = (
<ActivityLog
type={item.activityType}
message={item.message}
@@ -287,19 +291,27 @@ export function AgentStreamView({
metadata={item.metadata}
/>
);
break;
case "todo_list":
return (
content = (
<TodoListCard
provider={item.provider}
timestamp={item.timestamp.getTime()}
items={item.items}
/>
);
break;
default:
return null;
content = null;
}
if (!content) {
return null;
}
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
}
const pendingPermissionItems = useMemo(
@@ -318,24 +330,26 @@ export function AgentStreamView({
}
return (
<View style={stylesheet.listHeaderContent}>
{pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</View>
) : null}
<View style={stylesheet.contentWrapper}>
<View style={stylesheet.listHeaderContent}>
{pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</View>
) : null}
{showWorkingIndicator ? (
<View style={stylesheet.workingIndicatorWrapper}>
<WorkingIndicator />
</View>
) : null}
{showWorkingIndicator ? (
<View style={stylesheet.workingIndicatorWrapper}>
<WorkingIndicator />
</View>
) : null}
</View>
</View>
);
}, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]);
@@ -371,7 +385,7 @@ export function AgentStreamView({
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={stylesheet.emptyState}>
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
@@ -395,15 +409,17 @@ export function AgentStreamView({
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
>
<ChevronDown
size={24}
color={stylesheet.scrollToBottomIcon.color}
/>
</Pressable>
<View style={stylesheet.scrollToBottomInner}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
>
<ChevronDown
size={24}
color={stylesheet.scrollToBottomIcon.color}
/>
</Pressable>
</View>
</Animated.View>
)}
@@ -462,16 +478,18 @@ function WorkingIndicator() {
const dotOne = useSharedValue(0);
const dotTwo = useSharedValue(0);
const dotThree = useSharedValue(0);
const bounceDuration = 600;
const bounceDelayOffset = 160;
useEffect(() => {
const sharedValues = [dotOne, dotTwo, dotThree];
sharedValues.forEach((value, index) => {
value.value = withDelay(
index * 120,
index * bounceDelayOffset,
withRepeat(
withSequence(
withTiming(1, { duration: 320 }),
withTiming(0, { duration: 320 })
withTiming(1, { duration: bounceDuration }),
withTiming(0, { duration: bounceDuration })
),
-1,
true
@@ -487,19 +505,20 @@ function WorkingIndicator() {
};
}, [dotOne, dotTwo, dotThree]);
const translateDistance = -4;
const dotOneStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotOne.value * 0.7,
transform: [{ translateY: dotOne.value * -4 }],
transform: [{ translateY: dotOne.value * translateDistance }],
}));
const dotTwoStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotTwo.value * 0.7,
transform: [{ translateY: dotTwo.value * -4 }],
transform: [{ translateY: dotTwo.value * translateDistance }],
}));
const dotThreeStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotThree.value * 0.7,
transform: [{ translateY: dotThree.value * -4 }],
transform: [{ translateY: dotThree.value * translateDistance }],
}));
return (
@@ -566,8 +585,13 @@ function PermissionRequestCard({
() => ({
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
paragraph: {
marginBottom: theme.spacing[1],
@@ -823,10 +847,20 @@ const stylesheet = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.background,
},
contentWrapper: {
width: "100%",
maxWidth: MAX_CHAT_WIDTH,
alignSelf: "center",
},
list: {
flex: 1,
paddingHorizontal: theme.spacing[2],
},
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CHAT_WIDTH,
alignSelf: "center",
},
emptyState: {
flex: 1,
alignItems: "center",
@@ -840,18 +874,19 @@ const stylesheet = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
},
workingIndicatorWrapper: {
alignItems: "center",
alignItems: "flex-start",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
paddingHorizontal: 0,
paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.card,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
borderWidth: 0,
alignSelf: "flex-start",
},
workingIndicatorText: {
color: theme.colors.mutedForeground,
@@ -886,6 +921,12 @@ const stylesheet = StyleSheet.create((theme) => ({
alignItems: "center",
pointerEvents: "box-none",
},
scrollToBottomInner: {
width: "100%",
maxWidth: MAX_CHAT_WIDTH,
alignSelf: "center",
alignItems: "center",
},
scrollToBottomButton: {
width: 48,
height: 48,
File diff suppressed because it is too large Load Diff
+30 -3
View File
@@ -77,6 +77,9 @@ export function GlobalFooter() {
? FOOTER_HEIGHT * 2 + insets.bottom
: FOOTER_HEIGHT + insets.bottom;
const iconSize = 24;
const iconStyle = { width: iconSize, height: iconSize };
return (
<>
<Animated.View
@@ -110,7 +113,13 @@ export function GlobalFooter() {
pressed && styles.buttonPressed,
]}
>
<Users size={20} color={theme.colors.foreground} />
<View style={styles.footerIconWrapper}>
<Users
size={iconSize}
color={theme.colors.foreground}
style={iconStyle}
/>
</View>
<Text style={styles.footerButtonText}>Agents</Text>
</Pressable>
@@ -124,7 +133,13 @@ export function GlobalFooter() {
pressed && styles.buttonPressed,
]}
>
<Plus size={20} color={theme.colors.foreground} />
<View style={styles.footerIconWrapper}>
<Plus
size={iconSize}
color={theme.colors.foreground}
style={iconStyle}
/>
</View>
<Text style={styles.footerButtonText}>New Agent</Text>
</Pressable>
@@ -137,7 +152,13 @@ export function GlobalFooter() {
pressed && !ws.isConnected && styles.buttonPressed,
]}
>
<AudioLines size={20} color={theme.colors.foreground} />
<View style={styles.footerIconWrapper}>
<AudioLines
size={iconSize}
color={theme.colors.foreground}
style={iconStyle}
/>
</View>
<Text style={styles.footerButtonText}>Realtime</Text>
</Pressable>
</View>
@@ -180,6 +201,12 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3],
gap: theme.spacing[1],
},
footerIconWrapper: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
footerButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
@@ -8,9 +8,10 @@ import { ScreenHeader } from "./screen-header";
interface BackHeaderProps {
title?: string;
rightContent?: ReactNode;
onBack?: () => void;
}
export function BackHeader({ title, rightContent }: BackHeaderProps) {
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
@@ -18,7 +19,7 @@ export function BackHeader({ title, rightContent }: BackHeaderProps) {
left={
<>
<Pressable
onPress={() => router.back()}
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<ArrowLeft size={20} color={theme.colors.foreground} />
+84 -17
View File
@@ -21,7 +21,7 @@ import {
Brain,
Search,
} from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -166,6 +166,11 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
lineHeight: 24,
},
markdownText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
markdownParagraph: {
marginTop: 0,
marginBottom: theme.spacing[2],
@@ -241,22 +246,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
const markdownStyles = {
body: assistantMessageStylesheet.markdownBody,
paragraph: assistantMessageStylesheet.markdownParagraph,
strong: assistantMessageStylesheet.markdownStrong,
em: assistantMessageStylesheet.markdownEm,
code_inline: assistantMessageStylesheet.markdownCodeInline,
code_block: assistantMessageStylesheet.markdownCodeBlock,
fence: assistantMessageStylesheet.markdownFence,
link: assistantMessageStylesheet.markdownLink,
bullet_list: assistantMessageStylesheet.markdownList,
ordered_list: assistantMessageStylesheet.markdownList,
list_item: assistantMessageStylesheet.markdownListItem,
blockquote: assistantMessageStylesheet.markdownBlockquote,
blockquote_text: assistantMessageStylesheet.markdownBlockquoteText,
};
function isLikelyPathToken(value: string): boolean {
if (!value || value.length > 300) {
return false;
@@ -347,9 +336,87 @@ export const AssistantMessage = memo(function AssistantMessage({
isStreaming = false,
onInlinePathPress,
}: AssistantMessageProps) {
const { theme } = useUnistyles();
const fadeAnim = useRef(new Animated.Value(0.3)).current;
const lastPathRef = useRef<string | null>(null);
const markdownStyles = useMemo(
() => ({
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 24,
},
text: {
color: theme.colors.foreground,
},
paragraph: {
marginTop: 0,
marginBottom: theme.spacing[2],
},
strong: {
fontWeight: theme.fontWeight.bold,
},
em: {
fontStyle: "italic" as const,
},
code_inline: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
fontFamily: "monospace",
fontSize: 13,
},
code_block: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: "monospace",
fontSize: 13,
},
fence: {
backgroundColor: theme.colors.secondary,
borderColor: theme.colors.border,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[2],
fontFamily: "monospace",
fontSize: 13,
},
link: {
color: theme.colors.primary,
textDecorationLine: "underline" as const,
},
bullet_list: {
marginBottom: theme.spacing[2],
},
ordered_list: {
marginBottom: theme.spacing[2],
},
list_item: {
marginBottom: theme.spacing[1],
},
blockquote: {
backgroundColor: theme.colors.secondary,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
marginVertical: theme.spacing[2],
borderRadius: theme.borderRadius.sm,
},
blockquote_text: {
color: theme.colors.foreground,
fontStyle: "italic" as const,
},
}),
[theme]
);
const markdownRules = useMemo(() => {
if (!onInlinePathPress) {
return undefined;