refactor(app): simplify agent list UI and remove unused sidebar component
This commit is contained in:
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user