diff --git a/packages/app/assets/icons/claude.svg b/packages/app/assets/icons/claude.svg new file mode 100644 index 000000000..e29f32825 --- /dev/null +++ b/packages/app/assets/icons/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/packages/app/assets/icons/codex.svg b/packages/app/assets/icons/codex.svg new file mode 100644 index 000000000..50d94d6c1 --- /dev/null +++ b/packages/app/assets/icons/codex.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 5ea5261ae..e301752f8 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -673,7 +673,7 @@ function AgentScreenContent({ rightContent={ - + } diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2314e189a..ee8baa417 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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 = ( - - ); + const leftContent = ; return ( - {/* Border separator */} - - {/* Realtime controls - only when active */} {isRealtimeMode && ( ({ +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", diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 106d94b60..2154b86e0 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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>( ({ 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 ( [ + style={({ pressed, hovered }) => [ styles.agentItem, isSelected && styles.agentItemSelected, + hovered && styles.agentItemHovered, pressed && styles.agentItemPressed, ]} onPress={() => handleAgentPress(agent.serverId, agent.id)} onLongPress={() => handleAgentLongPress(agent)} > + {({ hovered }) => ( - + + {statusColor && ( + + )} {agent.title || "New Agent"} - - - {agent.serverLabel} - - - - {agent.cwd} + + {agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} ยท {timeAgo} - - - - - - {providerLabel} - - - - {isRunning ? ( - - - - Running - - - ) : agent.requiresAttention ? ( - - - - Finished - - - ) : null} - - - - {timeAgo} - - + )} ); }, - [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", diff --git a/packages/app/src/components/agent-sidebar.tsx b/packages/app/src/components/agent-sidebar.tsx deleted file mode 100644 index a6622d960..000000000 --- a/packages/app/src/components/agent-sidebar.tsx +++ /dev/null @@ -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 | 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 */} - - - - - {/* Sidebar with close gesture */} - - - {/* Header */} - - - Agents - - - - - - - {/* New Agent Button */} - { - onNewAgent(); - onClose(); - }} - > - - - New Agent - - - - {/* Agent List */} - - {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 ( - handleAgentSelect(agent.id, agent.serverId)} - > - - - {agent.title || "New Agent"} - - - - - {agent.cwd} - - - - - - {providerLabel} - - - - - - {statusLabel} - - - - - - ); - })} - - {agents.length === 0 && ( - - - No agents yet - - - )} - - - - - ); -} - -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, - }, -}); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 682505509..c48ea2c7a 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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) { ]} > - {agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label || + {agent.availableModes?.find((m) => m.id === agent.currentModeId) + ?.label || agent.currentModeId || "default"} @@ -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, diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 5d34a566d..c40773ebf 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -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({ )} {retryStatusText && (isRetrying || isFailed) && ( - + {retryStatusText} )} @@ -158,7 +162,12 @@ export function DictationOverlay({ } return ( - + - + {formatDuration(duration)} @@ -186,28 +200,52 @@ export function DictationOverlay({ {actionsDisabled ? ( - + ) : isFailed ? ( - + ) : ( <> - + - + )} @@ -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, diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index a43d22f82..a70c723da 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -22,7 +22,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { onPress={onBack ?? (() => router.back())} style={styles.backButton} > - + {title && ( @@ -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, }, })); diff --git a/packages/app/src/components/headers/home-header.tsx b/packages/app/src/components/headers/home-header.tsx index 069fbfdde..4af7603db 100644 --- a/packages/app/src/components/headers/home-header.tsx +++ b/packages/app/src/components/headers/home-header.tsx @@ -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, }, })); diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 907eb263e..33a78d308 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -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 ( - + {left} {right} @@ -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, }, diff --git a/packages/app/src/components/icons/claude-icon.tsx b/packages/app/src/components/icons/claude-icon.tsx new file mode 100644 index 000000000..17881ba8b --- /dev/null +++ b/packages/app/src/components/icons/claude-icon.tsx @@ -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 ( + + + + ); +} diff --git a/packages/app/src/components/icons/codex-icon.tsx b/packages/app/src/components/icons/codex-icon.tsx new file mode 100644 index 000000000..c12b5f3f0 --- /dev/null +++ b/packages/app/src/components/icons/codex-icon.tsx @@ -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 ( + + + + ); +} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 0e7911c37..3007015eb 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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 unknown }) | null>(null); + const textInputRef = useRef< + TextInput | (TextInput & { getNativeRef?: () => unknown }) | null + >(null); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const baselineInputHeightRef = useRef(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({ {images.map((image, index) => ( - + {onRemoveImage && ( - onRemoveImage(index)} style={styles.removeImageButton}> + onRemoveImage(index)} + style={styles.removeImageButton} + > )} @@ -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 && ( @@ -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 + } /> @@ -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, }, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index efa6514da..602f1a5d7 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -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;