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={ rightContent={
<View ref={menuButtonRef} collapsable={false}> <View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}> <Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.foreground} /> <MoreVertical size={20} color={theme.colors.mutedForeground} />
</Pressable> </Pressable>
</View> </View>
} }
@@ -22,8 +22,13 @@ import { AgentStatusBar } from "./agent-status-bar";
import { RealtimeControls } from "./realtime-controls"; import { RealtimeControls } from "./realtime-controls";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store"; 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 type { UseWebSocketReturn } from "@/hooks/use-websocket";
import { Theme } from "@/styles/theme";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -48,8 +53,12 @@ const MAX_INPUT_WIDTH = 960;
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated // Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
// entering/exiting animations (see react-native-reanimated#8422), so guard them. // entering/exiting animations (see react-native-reanimated#8422), so guard them.
const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android"; 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_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS
const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250); ? undefined
: FadeIn.duration(250);
const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS
? undefined
: FadeOut.duration(250);
export function AgentInputArea({ export function AgentInputArea({
agentId, agentId,
@@ -219,7 +228,11 @@ export function AgentInputArea({
} }
function handleSubmit(payload: MessagePayload) { function handleSubmit(payload: MessagePayload) {
void sendMessageWithContent(payload.text, payload.images, payload.forceSend); void sendMessageWithContent(
payload.text,
payload.images,
payload.forceSend
);
} }
async function handlePickImage() { async function handlePickImage() {
@@ -262,7 +275,8 @@ export function AgentInputArea({
useEffect(() => { useEffect(() => {
const existing = getDraftInput(agentId); const existing = getDraftInput(agentId);
const isSameText = existing?.text === userInput; const isSameText = existing?.text === userInput;
const existingImages: ImageAttachment[] = (existing?.images ?? []) as ImageAttachment[]; const existingImages: ImageAttachment[] = (existing?.images ??
[]) as ImageAttachment[];
const isSameImages = const isSameImages =
existingImages.length === selectedImages.length && existingImages.length === selectedImages.length &&
existingImages.every((img, idx) => { existingImages.every((img, idx) => {
@@ -387,17 +401,16 @@ export function AgentInputArea({
</> </>
); );
const leftContent = ( const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} />;
<AgentStatusBar agentId={agentId} serverId={serverId} />
);
return ( return (
<Animated.View <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 */} {/* Realtime controls - only when active */}
{isRealtimeMode && ( {isRealtimeMode && (
<Animated.View <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: { container: {
flexDirection: "column", flexDirection: "column",
position: "relative", position: "relative",
backgroundColor: theme.colors.background,
}, },
borderSeparator: { borderSeparator: {
height: theme.borderWidth[1], height: theme.borderWidth[1],
@@ -485,21 +499,20 @@ const styles = StyleSheet.create(((theme: any) => ({
inputAreaContainer: { inputAreaContainer: {
position: "relative", position: "relative",
minHeight: FOOTER_HEIGHT, minHeight: FOOTER_HEIGHT,
marginHorizontal: "auto",
alignItems: "center", alignItems: "center",
width: "100%", width: "100%",
overflow: "hidden", overflow: "hidden",
backgroundColor: theme.colors.background, padding: theme.spacing[4],
}, },
inputAreaContent: { inputAreaContent: {
width: "100%", width: "100%",
maxWidth: MAX_INPUT_WIDTH, maxWidth: MAX_INPUT_WIDTH,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
realtimeButton: { realtimeButton: {
width: 40, width: 34,
height: 40, height: 34,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accentForeground, backgroundColor: theme.colors.accentForeground,
alignItems: "center", alignItems: "center",
@@ -509,8 +522,8 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.palette.blue[600], backgroundColor: theme.colors.palette.blue[600],
}, },
cancelButton: { cancelButton: {
width: 40, width: BUTTON_SIZE,
height: 40, height: BUTTON_SIZE,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[500], backgroundColor: theme.colors.palette.red[500],
alignItems: "center", alignItems: "center",
+37 -109
View File
@@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from "react";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing"; import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing";
@@ -89,81 +88,47 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
const renderAgentItem = useCallback<ListRenderItem<AggregatedAgent>>( const renderAgentItem = useCallback<ListRenderItem<AggregatedAgent>>(
({ item: agent }) => { ({ item: agent }) => {
const timeAgo = formatTimeAgo(agent.lastActivityAt); const timeAgo = formatTimeAgo(agent.lastActivityAt);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
const isRunning = agent.status === "running"; const isRunning = agent.status === "running";
const isSelected = selectedAgentId === agent.id; const isSelected = selectedAgentId === agent.id;
const statusColor = isRunning ? "#3b82f6" : agent.requiresAttention ? "#22c55e" : null;
return ( return (
<Pressable <Pressable
style={({ pressed }) => [ style={({ pressed, hovered }) => [
styles.agentItem, styles.agentItem,
isSelected && styles.agentItemSelected, isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed, pressed && styles.agentItemPressed,
]} ]}
onPress={() => handleAgentPress(agent.serverId, agent.id)} onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)} onLongPress={() => handleAgentLongPress(agent)}
> >
{({ hovered }) => (
<View style={styles.agentContent}> <View style={styles.agentContent}>
<View style={styles.titleRow}> <View style={styles.row}>
{statusColor && (
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
)}
<Text <Text
style={styles.agentTitle} style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1} numberOfLines={1}
> >
{agent.title || "New Agent"} {agent.title || "New Agent"}
</Text> </Text>
<View style={[styles.hostBadge, { backgroundColor: theme.colors.muted }]}>
<Text
style={[styles.hostText, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{agent.serverLabel}
</Text>
</View>
</View> </View>
<Text style={styles.agentDirectory} numberOfLines={1}> <Text style={styles.secondaryRow} numberOfLines={1}>
{agent.cwd} {agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo}
</Text> </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> </View>
)}
</Pressable> </Pressable>
); );
}, },
[handleAgentLongPress, handleAgentPress, selectedAgentId, theme.colors.muted, theme.colors.mutedForeground, theme.colors.primary, theme.colors.primaryForeground] [handleAgentLongPress, handleAgentPress, selectedAgentId]
); );
const keyExtractor = useCallback( const keyExtractor = useCallback(
@@ -254,89 +219,52 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[4],
}, },
agentItem: { agentItem: {
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
backgroundColor: theme.colors.muted, backgroundColor: theme.colors.palette.zinc[950],
borderWidth: 1,
borderColor: "transparent",
}, },
agentItemSelected: { agentItemSelected: {
backgroundColor: theme.colors.accent, backgroundColor: theme.colors.palette.zinc[800],
borderColor: theme.colors.primary, },
agentItemHovered: {
backgroundColor: theme.colors.palette.zinc[850],
}, },
agentItemPressed: { agentItemPressed: {
opacity: 0.7, backgroundColor: theme.colors.palette.zinc[800],
backgroundColor: theme.colors.accent,
}, },
agentContent: { agentContent: {
flex: 1, flex: 1,
gap: theme.spacing[1],
}, },
titleRow: { row: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
marginBottom: theme.spacing[1],
}, },
agentTitle: { agentTitle: {
flex: 1, flex: 1,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.palette.zinc[200],
},
agentTitleHighlighted: {
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
hostBadge: { secondaryRow: {
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
},
hostText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
agentDirectory: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "300",
color: theme.colors.mutedForeground, 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: { statusDot: {
width: 6, width: 8,
height: 6, height: 8,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
}, },
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
timeAgo: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
},
sheetOverlay: { sheetOverlay: {
flex: 1, flex: 1,
justifyContent: "flex-end", 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) // Get the setAgentMode action (actions are stable, won't cause rerenders)
const setAgentMode = useSessionStore((state) => const setAgentMode = useSessionStore(
state.sessions[serverId]?.methods?.setAgentMode (state) => state.sessions[serverId]?.methods?.setAgentMode
); );
const [showModeSelector, setShowModeSelector] = useState(false); const [showModeSelector, setShowModeSelector] = useState(false);
@@ -47,7 +47,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
]} ]}
> >
<Text style={styles.modeBadgeText}> <Text style={styles.modeBadgeText}>
{agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label || {agent.availableModes?.find((m) => m.id === agent.currentModeId)
?.label ||
agent.currentModeId || agent.currentModeId ||
"default"} "default"}
</Text> </Text>
@@ -75,14 +76,14 @@ const styles = StyleSheet.create((theme) => ({
modeBadge: { modeBadge: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], backgroundColor: theme.colors.popover,
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.muted, borderRadius: theme.borderRadius["2xl"],
borderRadius: theme.borderRadius.full,
}, },
modeBadgePressed: { modeBadgePressed: {
backgroundColor: theme.colors.accent, backgroundColor: theme.colors.background,
}, },
modeBadgeText: { modeBadgeText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
@@ -24,7 +24,9 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = 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({ export function DictationControls({
@@ -122,7 +124,9 @@ export function DictationControls({
)} )}
</View> </View>
{retryStatusText && (isRetrying || isFailed) && ( {retryStatusText && (isRetrying || isFailed) && (
<Text style={[styles.statusLabel, { color: theme.colors.mutedForeground }]}> <Text
style={[styles.statusLabel, { color: theme.colors.mutedForeground }]}
>
{retryStatusText} {retryStatusText}
</Text> </Text>
)} )}
@@ -158,7 +162,12 @@ export function DictationOverlay({
} }
return ( return (
<View style={[overlayStyles.container, { backgroundColor: theme.colors.palette.blue[600] }]}> <View
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.palette.blue[600] },
]}
>
<Pressable <Pressable
onPress={handleCancel} onPress={handleCancel}
disabled={actionsDisabled && !isFailed} disabled={actionsDisabled && !isFailed}
@@ -178,7 +187,12 @@ export function DictationOverlay({
isSpeaking={false} isSpeaking={false}
orientation="horizontal" orientation="horizontal"
/> />
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}> <Text
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
{formatDuration(duration)} {formatDuration(duration)}
</Text> </Text>
</View> </View>
@@ -186,28 +200,52 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}> <View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? ( {actionsDisabled ? (
<View style={overlayStyles.loadingContainer}> <View style={overlayStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.palette.white} /> <ActivityIndicator
size="small"
color={theme.colors.palette.white}
/>
</View> </View>
) : isFailed ? ( ) : isFailed ? (
<Pressable <Pressable
onPress={onRetry} 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>
) : ( ) : (
<> <>
<Pressable <Pressable
onPress={onAccept} 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>
<Pressable <Pressable
onPress={onAcceptAndSend} 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> </Pressable>
</> </>
)} )}
@@ -290,6 +328,8 @@ const overlayStyles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
width: "100%",
borderRadius: theme.borderRadius["2xl"],
justifyContent: "space-between", justifyContent: "space-between",
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: OVERLAY_VERTICAL_PADDING, paddingVertical: OVERLAY_VERTICAL_PADDING,
@@ -22,7 +22,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
onPress={onBack ?? (() => router.back())} onPress={onBack ?? (() => router.back())}
style={styles.backButton} style={styles.backButton}
> >
<ArrowLeft size={20} color={theme.colors.foreground} /> <ArrowLeft size={20} color={theme.colors.mutedForeground} />
</Pressable> </Pressable>
{title && ( {title && (
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
@@ -39,16 +39,22 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
left: { left: {
gap: theme.spacing[3], gap: theme.spacing[2],
}, },
backButton: { backButton: {
padding: theme.spacing[3], padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, },
title: { title: {
flex: 1, flex: 1,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: {
xs: theme.fontWeight.semibold,
md: "400",
},
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
})); }));
@@ -51,7 +51,10 @@ export function HomeHeader({ onCreateAgent, onImportAgent }: HomeHeaderProps) {
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
iconButton: { iconButton: {
padding: theme.spacing[3], padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, },
})); }));
@@ -1,7 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { interface ScreenHeaderProps {
left?: ReactNode; left?: ReactNode;
@@ -17,10 +17,11 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) { export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const topPadding = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm" ? 8 : 4;
return ( return (
<View style={styles.header}> <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.row}>
<View style={[styles.left, leftStyle]}>{left}</View> <View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View> <View style={[styles.right, rightStyle]}>{right}</View>
@@ -39,8 +40,14 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingHorizontal: theme.spacing[3], paddingHorizontal: {
paddingBottom: theme.spacing[2], xs: theme.spacing[2],
md: theme.spacing[1],
},
paddingBottom: {
xs: theme.spacing[2],
md: theme.spacing[1],
},
borderBottomWidth: theme.borderWidth[1], borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border, 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, TextInputKeyPressEventData,
Image, Image,
Platform, Platform,
BackHandler,
} from "react-native"; } from "react-native";
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -57,7 +58,7 @@ export interface MessageInputProps {
onQueue?: (payload: MessagePayload) => void; onQueue?: (payload: MessagePayload) => void;
} }
const MIN_INPUT_HEIGHT = 50; const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web"; const IS_WEB = Platform.OS === "web";
@@ -98,7 +99,9 @@ export function MessageInput({
}: MessageInputProps) { }: MessageInputProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); 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 inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null); const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0); const overlayTransition = useSharedValue(0);
@@ -188,8 +191,14 @@ export function MessageInput({
// Animate overlay // Animate overlay
useEffect(() => { useEffect(() => {
const showOverlay = isDictating || isDictationProcessing || dictationStatus === "retrying" || dictationStatus === "failed"; const showOverlay =
overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200 }); isDictating ||
isDictationProcessing ||
dictationStatus === "retrying" ||
dictationStatus === "failed";
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
duration: 200,
});
}, [isDictating, isDictationProcessing, dictationStatus, overlayTransition]); }, [isDictating, isDictationProcessing, dictationStatus, overlayTransition]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({ const overlayAnimatedStyle = useAnimatedStyle(() => ({
@@ -234,7 +243,10 @@ export function MessageInput({
const handleSendMessage = useCallback(() => { const handleSendMessage = useCallback(() => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed && images.length === 0) return; 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) { if (isAgentRunning && onQueue) {
onQueue(payload); onQueue(payload);
} else { } else {
@@ -286,7 +298,10 @@ export function MessageInput({
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT; const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline; 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) { if (Math.abs(inputHeightRef.current - bounded) >= 1) {
inputHeightRef.current = bounded; inputHeightRef.current = bounded;
@@ -297,7 +312,10 @@ export function MessageInput({
} }
function setBoundedInputHeight(nextHeight: number) { 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; if (Math.abs(inputHeightRef.current - bounded) < 1) return;
inputHeightRef.current = bounded; inputHeightRef.current = bounded;
setInputHeight(bounded); setInputHeight(bounded);
@@ -364,9 +382,15 @@ export function MessageInput({
<View style={styles.imagePreviewContainer}> <View style={styles.imagePreviewContainer}>
{images.map((image, index) => ( {images.map((image, index) => (
<View key={`${image.uri}-${index}`} style={styles.imagePill}> <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 && ( {onRemoveImage && (
<Pressable onPress={() => onRemoveImage(index)} style={styles.removeImageButton}> <Pressable
onPress={() => onRemoveImage(index)}
style={styles.removeImageButton}
>
<X size={16} color={theme.colors.foreground} /> <X size={16} color={theme.colors.foreground} />
</Pressable> </Pressable>
)} )}
@@ -384,14 +408,20 @@ export function MessageInput({
placeholderTextColor={theme.colors.mutedForeground} placeholderTextColor={theme.colors.mutedForeground}
style={[ style={[
styles.textInput, styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT }, {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]} ]}
multiline multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT} scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && isConnected && !disabled} editable={!isDictating && isConnected && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} onKeyPress={
autoFocus={autoFocus} shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined
}
autoFocus={IS_WEB && autoFocus}
/> />
{/* Button row */} {/* Button row */}
@@ -418,10 +448,19 @@ export function MessageInput({
{shouldShowSendButton && ( {shouldShowSendButton && (
<Pressable <Pressable
onPress={handleSendMessage} onPress={handleSendMessage}
disabled={!isConnected || isSubmitDisabled || isSubmitLoading || disabled} disabled={
!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled
}
style={[ style={[
styles.sendButton, styles.sendButton,
(!isConnected || isSubmitDisabled || isSubmitLoading || disabled) && styles.buttonDisabled, (!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]} ]}
> >
<ArrowUp size={20} color="white" /> <ArrowUp size={20} color="white" />
@@ -458,8 +497,16 @@ export function MessageInput({
onCancel={handleCancelRecording} onCancel={handleCancelRecording}
onAccept={handleAcceptRecording} onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording} onAcceptAndSend={handleAcceptAndSendRecording}
onRetry={dictationStatus === "failed" ? handleRetryFailedRecording : undefined} onRetry={
onDiscard={dictationStatus === "failed" ? handleDiscardFailedRecording : undefined} dictationStatus === "failed"
? handleRetryFailedRecording
: undefined
}
onDiscard={
dictationStatus === "failed"
? handleDiscardFailedRecording
: undefined
}
/> />
</Animated.View> </Animated.View>
</View> </View>
@@ -473,6 +520,18 @@ const styles = StyleSheet.create(((theme: any) => ({
inputWrapper: { inputWrapper: {
flexDirection: "column", flexDirection: "column",
gap: theme.spacing[3], 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: { imagePreviewContainer: {
flexDirection: "row", flexDirection: "row",
@@ -500,12 +559,9 @@ const styles = StyleSheet.create(((theme: any) => ({
}, },
textInput: { textInput: {
width: "100%", width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.lg * 1.4, lineHeight: theme.fontSize.lg * 1.4,
...(IS_WEB ...(IS_WEB
? { ? {
@@ -531,16 +587,15 @@ const styles = StyleSheet.create(((theme: any) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
attachButton: { attachButton: {
width: 40, width: 20,
height: 40, height: 20,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
voiceButton: { voiceButton: {
width: 40, width: 34,
height: 40, height: 34,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
@@ -548,8 +603,8 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.destructive, backgroundColor: theme.colors.destructive,
}, },
sendButton: { sendButton: {
width: 40, width: 34,
height: 40, height: 34,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[600], backgroundColor: theme.colors.palette.blue[600],
alignItems: "center", alignItems: "center",
@@ -560,8 +615,13 @@ const styles = StyleSheet.create(((theme: any) => ({
}, },
overlayContainer: { overlayContainer: {
position: "absolute", position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
top: 0, top: 0,
left: 0, left: 0,
width: "100%",
height: "100%",
right: 0, right: 0,
bottom: 0, bottom: 0,
}, },
+5 -1
View File
@@ -14,7 +14,9 @@ export const baseColors = {
600: "#52525b", 600: "#52525b",
700: "#3f3f46", 700: "#3f3f46",
800: "#27272a", 800: "#27272a",
850: "#1a1a1d",
900: "#18181b", 900: "#18181b",
950: "#121214",
}, },
// Gray scale // Gray scale
@@ -120,6 +122,7 @@ const lightSemanticColors = {
destructive: "#ef4444", destructive: "#ef4444",
destructiveForeground: "#fafafa", destructiveForeground: "#fafafa",
border: "#e4e4e7", border: "#e4e4e7",
accentBorder: "#3f3f46",
input: "#e4e4e7", input: "#e4e4e7",
ring: "#18181b", ring: "#18181b",
} as const; } as const;
@@ -141,7 +144,8 @@ const darkSemanticColors = {
accentForeground: "#fafafa", accentForeground: "#fafafa",
destructive: "#7f1d1d", destructive: "#7f1d1d",
destructiveForeground: "#fafafa", destructiveForeground: "#fafafa",
border: "#27272a", border: "#27272e",
accentBorder: "#414146",
input: "#27272a", input: "#27272a",
ring: "#d4d4d8", ring: "#d4d4d8",
} as const; } as const;