refactor(app): simplify agent list UI and remove unused sidebar component

This commit is contained in:
Mohamed Boudra
2026-01-03 19:39:24 +07:00
parent e8890a2fc1
commit a12af74c7b
15 changed files with 278 additions and 536 deletions
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -673,7 +673,7 @@ function AgentScreenContent({
rightContent={
<View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.foreground} />
<MoreVertical size={20} color={theme.colors.mutedForeground} />
</Pressable>
</View>
}
@@ -22,8 +22,13 @@ import { AgentStatusBar } from "./agent-status-bar";
import { RealtimeControls } from "./realtime-controls";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import { MessageInput, type MessagePayload, type ImageAttachment } from "./message-input";
import {
MessageInput,
type MessagePayload,
type ImageAttachment,
} from "./message-input";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
import { Theme } from "@/styles/theme";
type QueuedMessage = {
id: string;
@@ -48,8 +53,12 @@ const MAX_INPUT_WIDTH = 960;
// 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 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);
export function AgentInputArea({
agentId,
@@ -219,7 +228,11 @@ export function AgentInputArea({
}
function handleSubmit(payload: MessagePayload) {
void sendMessageWithContent(payload.text, payload.images, payload.forceSend);
void sendMessageWithContent(
payload.text,
payload.images,
payload.forceSend
);
}
async function handlePickImage() {
@@ -262,7 +275,8 @@ export function AgentInputArea({
useEffect(() => {
const existing = getDraftInput(agentId);
const isSameText = existing?.text === userInput;
const existingImages: ImageAttachment[] = (existing?.images ?? []) as ImageAttachment[];
const existingImages: ImageAttachment[] = (existing?.images ??
[]) as ImageAttachment[];
const isSameImages =
existingImages.length === selectedImages.length &&
existingImages.every((img, idx) => {
@@ -387,17 +401,16 @@ export function AgentInputArea({
</>
);
const leftContent = (
<AgentStatusBar agentId={agentId} serverId={serverId} />
);
const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} />;
return (
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
style={[
styles.container,
{ paddingBottom: insets.bottom },
keyboardAnimatedStyle,
]}
>
{/* Border separator */}
<View style={styles.borderSeparator} />
{/* Realtime controls - only when active */}
{isRealtimeMode && (
<Animated.View
@@ -469,11 +482,12 @@ export function AgentInputArea({
);
}
const styles = StyleSheet.create(((theme: any) => ({
const BUTTON_SIZE = 40;
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
flexDirection: "column",
position: "relative",
backgroundColor: theme.colors.background,
},
borderSeparator: {
height: theme.borderWidth[1],
@@ -485,21 +499,20 @@ const styles = StyleSheet.create(((theme: any) => ({
inputAreaContainer: {
position: "relative",
minHeight: FOOTER_HEIGHT,
marginHorizontal: "auto",
alignItems: "center",
width: "100%",
overflow: "hidden",
backgroundColor: theme.colors.background,
padding: theme.spacing[4],
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_INPUT_WIDTH,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
gap: theme.spacing[3],
},
realtimeButton: {
width: 40,
height: 40,
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accentForeground,
alignItems: "center",
@@ -509,8 +522,8 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.palette.blue[600],
},
cancelButton: {
width: 40,
height: 40,
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[500],
alignItems: "center",
+37 -109
View File
@@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing";
@@ -89,81 +88,47 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
const renderAgentItem = useCallback<ListRenderItem<AggregatedAgent>>(
({ item: agent }) => {
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
const isRunning = agent.status === "running";
const isSelected = selectedAgentId === agent.id;
const statusColor = isRunning ? "#3b82f6" : agent.requiresAttention ? "#22c55e" : null;
return (
<Pressable
style={({ pressed }) => [
style={({ pressed, hovered }) => [
styles.agentItem,
isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.titleRow}>
<View style={styles.row}>
{statusColor && (
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
)}
<Text
style={styles.agentTitle}
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
<View style={[styles.hostBadge, { backgroundColor: theme.colors.muted }]}>
<Text
style={[styles.hostText, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{agent.serverLabel}
</Text>
</View>
</View>
<Text style={styles.agentDirectory} numberOfLines={1}>
{agent.cwd}
<Text style={styles.secondaryRow} numberOfLines={1}>
{agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo}
</Text>
<View style={styles.statusRow}>
<View style={styles.statusGroup}>
<View
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
>
<Text
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
{isRunning ? (
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: "#3b82f6" }]} />
<Text style={[styles.statusText, { color: "#3b82f6" }]}>
Running
</Text>
</View>
) : agent.requiresAttention ? (
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: "#22c55e" }]} />
<Text style={[styles.statusText, { color: "#22c55e" }]}>
Finished
</Text>
</View>
) : null}
</View>
<Text style={styles.timeAgo}>
{timeAgo}
</Text>
</View>
</View>
)}
</Pressable>
);
},
[handleAgentLongPress, handleAgentPress, selectedAgentId, theme.colors.muted, theme.colors.mutedForeground, theme.colors.primary, theme.colors.primaryForeground]
[handleAgentLongPress, handleAgentPress, selectedAgentId]
);
const keyExtractor = useCallback(
@@ -254,89 +219,52 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[4],
},
agentItem: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.muted,
borderWidth: 1,
borderColor: "transparent",
backgroundColor: theme.colors.palette.zinc[950],
},
agentItemSelected: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.primary,
backgroundColor: theme.colors.palette.zinc[800],
},
agentItemHovered: {
backgroundColor: theme.colors.palette.zinc[850],
},
agentItemPressed: {
opacity: 0.7,
backgroundColor: theme.colors.accent,
backgroundColor: theme.colors.palette.zinc[800],
},
agentContent: {
flex: 1,
gap: theme.spacing[1],
},
titleRow: {
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[1],
},
agentTitle: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.palette.zinc[200],
},
agentTitleHighlighted: {
color: theme.colors.foreground,
},
hostBadge: {
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
},
hostText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
agentDirectory: {
secondaryRow: {
fontSize: theme.fontSize.sm,
fontWeight: "300",
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[1],
},
statusRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
statusGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
providerBadge: {
borderRadius: theme.borderRadius.full,
},
providerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
statusDot: {
width: 6,
height: 6,
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
timeAgo: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
sheetOverlay: {
flex: 1,
justifyContent: "flex-end",
@@ -1,350 +0,0 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Plus, X } from "lucide-react-native";
import { useEffect, useState } from "react";
import { GestureDetector, Gesture } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
cancelAnimation,
runOnJS,
Easing,
type SharedValue,
} from "react-native-reanimated";
import type { Agent } from "@/contexts/session-context";
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
interface AgentSidebarProps {
isOpen: boolean;
agents: Agent[];
activeAgentId: string | null;
onClose: () => void;
onSelectAgent: (serverId: string, agentId: string) => void;
onNewAgent: () => void;
edgeSwipeTranslateX?: SharedValue<number> | null;
}
export function AgentSidebar({
isOpen,
agents,
activeAgentId,
onClose,
onSelectAgent,
onNewAgent,
edgeSwipeTranslateX,
}: AgentSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const savedTranslateX = useSharedValue(0);
const [isVisible, setIsVisible] = useState(isOpen);
const fallbackTranslateX = useSharedValue(-300);
const translateX = edgeSwipeTranslateX ?? fallbackTranslateX;
useEffect(() => {
if (isOpen) {
setIsVisible(true);
// Only animate if starting from fully closed position (not mid-animation from edge swipe)
if (translateX.value === -300) {
translateX.value = withTiming(0, {
duration: 150,
easing: Easing.out(Easing.ease),
});
}
} else {
// Only animate if starting from fully open position
if (translateX.value === 0) {
translateX.value = withTiming(-300, {
duration: 150,
easing: Easing.out(Easing.ease),
}, (finished) => {
if (finished) {
runOnJS(setIsVisible)(false);
}
});
} else if (translateX.value === -300) {
// Already closed, just hide
setIsVisible(false);
}
}
}, [isOpen]);
function handleAgentSelect(agentId: string, serverId: string) {
onSelectAgent(serverId, agentId);
onClose();
}
// Pan gesture for closing sidebar (swipe left on the sidebar)
const closeGesture = Gesture.Pan()
.enabled(isOpen) // Only active when sidebar is open
.activeOffsetX(-10) // Require leftward swipe
.onBegin(() => {
cancelAnimation(translateX);
savedTranslateX.value = translateX.value;
})
.onChange((event) => {
// Directly update translateX - clamp between -300 and 0
const newTranslateX = Math.max(-300, Math.min(0, savedTranslateX.value + event.translationX));
translateX.value = newTranslateX;
})
.onEnd((event) => {
const shouldClose = translateX.value < -150 || event.velocityX < -500;
if (shouldClose) {
translateX.value = withTiming(-300, {
duration: 150,
easing: Easing.out(Easing.ease),
}, (finished) => {
if (finished) {
runOnJS(setIsVisible)(false);
runOnJS(onClose)();
}
});
} else {
translateX.value = withTiming(0, {
duration: 150,
easing: Easing.out(Easing.ease),
});
}
});
const animatedSidebarStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const animatedBackdropStyle = useAnimatedStyle(() => {
// Map translateX (-300 to 0) to opacity (0 to 1)
const opacity = (translateX.value + 300) / 300;
return { opacity };
});
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
<>
{/* Backdrop - fade in/out with sidebar */}
<Pressable
onPress={onClose}
style={StyleSheet.absoluteFill}
pointerEvents={isOpen ? "auto" : "none"}
>
<Animated.View style={[styles.backdrop, animatedBackdropStyle]} />
</Pressable>
{/* Sidebar with close gesture */}
<GestureDetector gesture={closeGesture}>
<Animated.View
style={[
styles.sidebar,
{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 16,
backgroundColor: theme.colors.background,
},
animatedSidebarStyle,
]}
>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.colors.foreground }]}>
Agents
</Text>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={24} color={theme.colors.mutedForeground} />
</Pressable>
</View>
{/* New Agent Button */}
<Pressable
style={[styles.newAgentButton, { backgroundColor: theme.colors.primary }]}
onPress={() => {
onNewAgent();
onClose();
}}
>
<Plus size={20} color={theme.colors.primaryForeground} />
<Text style={[styles.newAgentText, { color: theme.colors.primaryForeground }]}>
New Agent
</Text>
</Pressable>
{/* Agent List */}
<ScrollView style={styles.agentList} showsVerticalScrollIndicator={false}>
{agents.map((agent) => {
const isActive = agent.id === activeAgentId;
const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
return (
<Pressable
key={agent.id}
style={[
styles.agentItem,
{ backgroundColor: isActive ? theme.colors.secondary : "transparent" },
]}
onPress={() => handleAgentSelect(agent.id, agent.serverId)}
>
<View style={styles.agentContent}>
<Text
style={[
styles.agentTitle,
{ color: theme.colors.foreground },
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
<View style={styles.directoryRow}>
<Text
style={[styles.agentDirectory, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{agent.cwd}
</Text>
<View style={styles.statusBadge}>
<View style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}>
<Text
style={[
styles.providerText,
{ color: theme.colors.mutedForeground },
]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
</View>
</View>
</View>
</Pressable>
);
})}
{agents.length === 0 && (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.colors.mutedForeground }]}>
No agents yet
</Text>
</View>
)}
</ScrollView>
</Animated.View>
</GestureDetector>
</>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
zIndex: 998,
},
sidebar: {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 300,
zIndex: 999,
paddingHorizontal: 16,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
headerTitle: {
fontSize: 24,
fontWeight: "700",
},
closeButton: {
padding: 4,
},
newAgentButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
marginBottom: 16,
gap: 8,
},
newAgentText: {
fontSize: 16,
fontWeight: "600",
},
agentList: {
flex: 1,
},
agentItem: {
paddingVertical: 12,
paddingHorizontal: 12,
borderRadius: 8,
marginBottom: 4,
},
agentContent: {
flex: 1,
},
agentTitle: {
fontSize: 15,
fontWeight: "500",
marginBottom: 2,
},
directoryRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
agentDirectory: {
flex: 1,
fontSize: 12,
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
providerBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
},
providerText: {
fontSize: 11,
fontWeight: "600",
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
},
statusText: {
fontSize: 11,
fontWeight: "500",
},
emptyState: {
paddingVertical: 32,
alignItems: "center",
},
emptyText: {
fontSize: 14,
},
});
@@ -19,8 +19,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
);
// Get the setAgentMode action (actions are stable, won't cause rerenders)
const setAgentMode = useSessionStore((state) =>
state.sessions[serverId]?.methods?.setAgentMode
const setAgentMode = useSessionStore(
(state) => state.sessions[serverId]?.methods?.setAgentMode
);
const [showModeSelector, setShowModeSelector] = useState(false);
@@ -47,7 +47,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
]}
>
<Text style={styles.modeBadgeText}>
{agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
?.label ||
agent.currentModeId ||
"default"}
</Text>
@@ -75,14 +76,14 @@ const styles = StyleSheet.create((theme) => ({
modeBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.popover,
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.full,
borderRadius: theme.borderRadius["2xl"],
},
modeBadgePressed: {
backgroundColor: theme.colors.accent,
backgroundColor: theme.colors.background,
},
modeBadgeText: {
color: theme.colors.mutedForeground,
@@ -24,7 +24,9 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
}
export function DictationControls({
@@ -122,7 +124,9 @@ export function DictationControls({
)}
</View>
{retryStatusText && (isRetrying || isFailed) && (
<Text style={[styles.statusLabel, { color: theme.colors.mutedForeground }]}>
<Text
style={[styles.statusLabel, { color: theme.colors.mutedForeground }]}
>
{retryStatusText}
</Text>
)}
@@ -158,7 +162,12 @@ export function DictationOverlay({
}
return (
<View style={[overlayStyles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
<View
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.palette.blue[600] },
]}
>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
@@ -178,7 +187,12 @@ export function DictationOverlay({
isSpeaking={false}
orientation="horizontal"
/>
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
<Text
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
{formatDuration(duration)}
</Text>
</View>
@@ -186,28 +200,52 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
<ActivityIndicator
size="small"
color={theme.colors.palette.white}
/>
</View>
) : isFailed ? (
<Pressable
onPress={onRetry}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
>
<RefreshCcw size={20} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
<RefreshCcw
size={20}
color={theme.colors.palette.blue[600]}
strokeWidth={2.5}
/>
</Pressable>
) : (
<>
<Pressable
onPress={onAccept}
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
>
<Check size={20} color={theme.colors.palette.white} strokeWidth={2.5} />
<Check
size={20}
color={theme.colors.palette.white}
strokeWidth={2.5}
/>
</Pressable>
<Pressable
onPress={onAcceptAndSend}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
>
<ArrowUp size={20} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
<ArrowUp
size={20}
color={theme.colors.palette.blue[600]}
strokeWidth={2.5}
/>
</Pressable>
</>
)}
@@ -290,6 +328,8 @@ const overlayStyles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
width: "100%",
borderRadius: theme.borderRadius["2xl"],
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: OVERLAY_VERTICAL_PADDING,
@@ -22,7 +22,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<ArrowLeft size={20} color={theme.colors.foreground} />
<ArrowLeft size={20} color={theme.colors.mutedForeground} />
</Pressable>
{title && (
<Text style={styles.title} numberOfLines={1}>
@@ -39,16 +39,22 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const styles = StyleSheet.create((theme) => ({
left: {
gap: theme.spacing[3],
gap: theme.spacing[2],
},
backButton: {
padding: theme.spacing[3],
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
title: {
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
fontWeight: {
xs: theme.fontWeight.semibold,
md: "400",
},
color: theme.colors.foreground,
},
}));
@@ -51,7 +51,10 @@ export function HomeHeader({ onCreateAgent, onImportAgent }: HomeHeaderProps) {
const styles = StyleSheet.create((theme) => ({
iconButton: {
padding: theme.spacing[3],
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -17,10 +17,11 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const insets = useSafeAreaInsets();
const { theme } = useUnistyles();
const topPadding = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm" ? 8 : 4;
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + 12 }]}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View style={styles.row}>
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
@@ -39,8 +40,14 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
paddingHorizontal: {
xs: theme.spacing[2],
md: theme.spacing[1],
},
paddingBottom: {
xs: theme.spacing[2],
md: theme.spacing[1],
},
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
@@ -0,0 +1,14 @@
import Svg, { Path } from "react-native-svg";
interface ClaudeIconProps {
size?: number;
color?: string;
}
export function ClaudeIcon({ size = 16, color = "currentColor" }: ClaudeIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color} fillRule="evenodd">
<Path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</Svg>
);
}
@@ -0,0 +1,14 @@
import Svg, { Path } from "react-native-svg";
interface CodexIconProps {
size?: number;
color?: string;
}
export function CodexIcon({ size = 16, color = "currentColor" }: CodexIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color} fillRule="evenodd">
<Path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
</Svg>
);
}
+87 -27
View File
@@ -7,6 +7,7 @@ import {
TextInputKeyPressEventData,
Image,
Platform,
BackHandler,
} from "react-native";
import { useState, useRef, useCallback, useEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -57,7 +58,7 @@ export interface MessageInputProps {
onQueue?: (payload: MessagePayload) => void;
}
const MIN_INPUT_HEIGHT = 50;
const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web";
@@ -98,7 +99,9 @@ export function MessageInput({
}: MessageInputProps) {
const { theme } = useUnistyles();
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>(null);
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0);
@@ -188,8 +191,14 @@ export function MessageInput({
// Animate overlay
useEffect(() => {
const showOverlay = isDictating || isDictationProcessing || dictationStatus === "retrying" || dictationStatus === "failed";
overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200 });
const showOverlay =
isDictating ||
isDictationProcessing ||
dictationStatus === "retrying" ||
dictationStatus === "failed";
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
duration: 200,
});
}, [isDictating, isDictationProcessing, dictationStatus, overlayTransition]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
@@ -234,7 +243,10 @@ export function MessageInput({
const handleSendMessage = useCallback(() => {
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
const payload = { text: trimmed, images: images.length > 0 ? images : undefined };
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
};
if (isAgentRunning && onQueue) {
onQueue(payload);
} else {
@@ -286,7 +298,10 @@ export function MessageInput({
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline;
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget));
const bounded = Math.max(
MIN_INPUT_HEIGHT,
Math.min(MAX_INPUT_HEIGHT, rawTarget)
);
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
inputHeightRef.current = bounded;
@@ -297,7 +312,10 @@ export function MessageInput({
}
function setBoundedInputHeight(nextHeight: number) {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
const bounded = Math.max(
MIN_INPUT_HEIGHT,
Math.min(MAX_INPUT_HEIGHT, nextHeight)
);
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
inputHeightRef.current = bounded;
setInputHeight(bounded);
@@ -364,9 +382,15 @@ export function MessageInput({
<View style={styles.imagePreviewContainer}>
{images.map((image, index) => (
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
<Image
source={{ uri: image.uri }}
style={styles.imageThumbnail}
/>
{onRemoveImage && (
<Pressable onPress={() => onRemoveImage(index)} style={styles.removeImageButton}>
<Pressable
onPress={() => onRemoveImage(index)}
style={styles.removeImageButton}
>
<X size={16} color={theme.colors.foreground} />
</Pressable>
)}
@@ -384,14 +408,20 @@ export function MessageInput({
placeholderTextColor={theme.colors.mutedForeground}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
{
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && isConnected && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
autoFocus={autoFocus}
onKeyPress={
shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined
}
autoFocus={IS_WEB && autoFocus}
/>
{/* Button row */}
@@ -418,10 +448,19 @@ export function MessageInput({
{shouldShowSendButton && (
<Pressable
onPress={handleSendMessage}
disabled={!isConnected || isSubmitDisabled || isSubmitLoading || disabled}
disabled={
!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled
}
style={[
styles.sendButton,
(!isConnected || isSubmitDisabled || isSubmitLoading || disabled) && styles.buttonDisabled,
(!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
@@ -458,8 +497,16 @@ export function MessageInput({
onCancel={handleCancelRecording}
onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording}
onRetry={dictationStatus === "failed" ? handleRetryFailedRecording : undefined}
onDiscard={dictationStatus === "failed" ? handleDiscardFailedRecording : undefined}
onRetry={
dictationStatus === "failed"
? handleRetryFailedRecording
: undefined
}
onDiscard={
dictationStatus === "failed"
? handleDiscardFailedRecording
: undefined
}
/>
</Animated.View>
</View>
@@ -473,6 +520,18 @@ const styles = StyleSheet.create(((theme: any) => ({
inputWrapper: {
flexDirection: "column",
gap: theme.spacing[3],
backgroundColor: theme.colors.muted,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.accentBorder,
borderRadius: theme.borderRadius["2xl"],
paddingVertical: {
xs: theme.spacing[2],
md: theme.spacing[4],
},
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
},
imagePreviewContainer: {
flexDirection: "row",
@@ -500,12 +559,9 @@ const styles = StyleSheet.create(((theme: any) => ({
},
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.lg,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.lg * 1.4,
...(IS_WEB
? {
@@ -531,16 +587,15 @@ const styles = StyleSheet.create(((theme: any) => ({
gap: theme.spacing[2],
},
attachButton: {
width: 40,
height: 40,
width: 20,
height: 20,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 40,
height: 40,
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
},
@@ -548,8 +603,8 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: 40,
height: 40,
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[600],
alignItems: "center",
@@ -560,8 +615,13 @@ const styles = StyleSheet.create(((theme: any) => ({
},
overlayContainer: {
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
top: 0,
left: 0,
width: "100%",
height: "100%",
right: 0,
bottom: 0,
},
+5 -1
View File
@@ -14,7 +14,9 @@ export const baseColors = {
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
850: "#1a1a1d",
900: "#18181b",
950: "#121214",
},
// Gray scale
@@ -120,6 +122,7 @@ const lightSemanticColors = {
destructive: "#ef4444",
destructiveForeground: "#fafafa",
border: "#e4e4e7",
accentBorder: "#3f3f46",
input: "#e4e4e7",
ring: "#18181b",
} as const;
@@ -141,7 +144,8 @@ const darkSemanticColors = {
accentForeground: "#fafafa",
destructive: "#7f1d1d",
destructiveForeground: "#fafafa",
border: "#27272a",
border: "#27272e",
accentBorder: "#414146",
input: "#27272a",
ring: "#d4d4d8",
} as const;