534 lines
13 KiB
TypeScript
534 lines
13 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
RefreshControl,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { useFocusEffect } from "@react-navigation/native";
|
|
import { useSocket } from "@/contexts/SocketContext";
|
|
import {
|
|
chatTitle,
|
|
formatMessageCount,
|
|
formatRelativeDate,
|
|
projectDisplayName,
|
|
sessionsForProject,
|
|
shortenPath,
|
|
} from "@/lib/session-utils";
|
|
import { colors, monoFont, tintFor } from "@/lib/theme";
|
|
import type { LiveRuntimeInfo, PersistedSessionInfo } from "@/lib/types";
|
|
|
|
function ChatRow({
|
|
title,
|
|
meta,
|
|
badge,
|
|
loading,
|
|
isLast,
|
|
onPress,
|
|
}: {
|
|
title: string;
|
|
meta?: string;
|
|
badge?: { label: string; tone: "active" | "streaming" };
|
|
loading?: boolean;
|
|
isLast?: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [
|
|
styles.row,
|
|
pressed && styles.rowPressed,
|
|
loading && styles.rowLoading,
|
|
]}
|
|
onPress={onPress}
|
|
disabled={loading}
|
|
>
|
|
<View style={styles.rowIcon}>
|
|
<Ionicons name="chatbubble-outline" size={16} color={colors.textSecondary} />
|
|
</View>
|
|
<View style={[styles.rowMain, !isLast && styles.rowDivider]}>
|
|
<View style={styles.rowText}>
|
|
<View style={styles.rowTitleRow}>
|
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
|
{title}
|
|
</Text>
|
|
{badge ? (
|
|
<View
|
|
style={[styles.badge, badge.tone === "streaming" && styles.badgeStreaming]}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.badgeDot,
|
|
badge.tone === "streaming" ? styles.badgeDotStreaming : styles.badgeDotActive,
|
|
]}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.badgeText,
|
|
badge.tone === "streaming" && styles.badgeTextStreaming,
|
|
]}
|
|
>
|
|
{badge.label}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
{meta ? <Text style={styles.rowMeta}>{meta}</Text> : null}
|
|
</View>
|
|
{loading ? (
|
|
<ActivityIndicator color={colors.textSecondary} size="small" />
|
|
) : (
|
|
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function EmptyChats() {
|
|
return (
|
|
<View style={styles.emptyChats}>
|
|
<Ionicons name="chatbubbles-outline" size={20} color={colors.textMuted} />
|
|
<Text style={styles.emptyChatsText}>No past chats in this project yet.</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function ProjectScreen() {
|
|
const { cwd: cwdParam } = useLocalSearchParams<{ cwd: string }>();
|
|
const projectCwd = cwdParam ?? "";
|
|
const navigation = useNavigation();
|
|
|
|
const {
|
|
connected,
|
|
error,
|
|
persistedSessions,
|
|
liveRuntimes,
|
|
refreshSessions,
|
|
openRuntime,
|
|
waitForRuntime,
|
|
attachRuntime,
|
|
removeProject,
|
|
} = useSocket();
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [openingPath, setOpeningPath] = useState<string | null>(null);
|
|
|
|
const { live, persisted } = sessionsForProject(projectCwd, liveRuntimes, persistedSessions);
|
|
const projectName = projectDisplayName(projectCwd);
|
|
const tint = useMemo(() => tintFor(projectName), [projectName]);
|
|
|
|
useLayoutEffect(() => {
|
|
navigation.setOptions({ title: projectName });
|
|
}, [navigation, projectName]);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
refreshSessions();
|
|
setTimeout(() => setRefreshing(false), 600);
|
|
}, [refreshSessions]);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (connected) {
|
|
refreshSessions();
|
|
}
|
|
}, [connected, refreshSessions]),
|
|
);
|
|
|
|
const openLive = (runtime: LiveRuntimeInfo) => {
|
|
attachRuntime(runtime.runtimeId);
|
|
router.push(`/chat/${runtime.runtimeId}`);
|
|
};
|
|
|
|
const resumePersisted = async (session: PersistedSessionInfo) => {
|
|
setOpeningPath(session.path);
|
|
openRuntime(session.path);
|
|
try {
|
|
const runtime = await waitForRuntime("opened");
|
|
attachRuntime(runtime.runtimeId);
|
|
router.push(`/chat/${runtime.runtimeId}`);
|
|
} catch {
|
|
// error surfaced via socket context
|
|
} finally {
|
|
setOpeningPath(null);
|
|
}
|
|
};
|
|
|
|
const startNewChat = () => {
|
|
if (!projectCwd) {
|
|
return;
|
|
}
|
|
|
|
router.push(`/chat/new?cwd=${encodeURIComponent(projectCwd)}`);
|
|
};
|
|
|
|
const handleRemoveProject = async () => {
|
|
await removeProject(projectCwd);
|
|
router.back();
|
|
};
|
|
|
|
if (!projectCwd) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<Text style={styles.muted}>Project not found.</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{error ? (
|
|
<View style={styles.errorBanner}>
|
|
<Text style={styles.errorBannerText}>{error}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<ScrollView
|
|
contentContainerStyle={styles.scroll}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => void handleRefresh()}
|
|
tintColor={colors.text}
|
|
/>
|
|
}
|
|
>
|
|
<View style={styles.hero}>
|
|
<View style={[styles.heroTile, { backgroundColor: tint.bg }]}>
|
|
<Text style={[styles.heroLetter, { color: tint.fg }]}>
|
|
{projectName.charAt(0).toUpperCase()}
|
|
</Text>
|
|
{live.length > 0 ? <View style={styles.heroActiveDot} /> : null}
|
|
</View>
|
|
<View style={styles.heroText}>
|
|
<Text style={styles.heroName}>{projectName}</Text>
|
|
<Text style={styles.heroPath} numberOfLines={1}>
|
|
{shortenPath(projectCwd)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<Pressable
|
|
style={({ pressed }) => [styles.newChatButton, pressed && styles.pressedDim]}
|
|
onPress={startNewChat}
|
|
>
|
|
<View style={styles.newChatIcon}>
|
|
<Ionicons name="add" size={22} color={colors.text} />
|
|
</View>
|
|
<View style={styles.newChatText}>
|
|
<Text style={styles.newChatTitle}>New chat</Text>
|
|
<Text style={styles.newChatHint}>Start a fresh conversation in this project</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={18} color={colors.text} />
|
|
</Pressable>
|
|
|
|
{live.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>
|
|
Active now · {live.length}
|
|
</Text>
|
|
<View style={styles.listCard}>
|
|
{live.map((runtime, index) => (
|
|
<ChatRow
|
|
key={runtime.runtimeId}
|
|
title={chatTitle(runtime.lastMessage, runtime.sessionFile ?? runtime.runtimeId)}
|
|
meta="Currently open"
|
|
badge={
|
|
runtime.isStreaming
|
|
? { label: "Working", tone: "streaming" }
|
|
: { label: "Open", tone: "active" }
|
|
}
|
|
isLast={index === live.length - 1}
|
|
onPress={() => openLive(runtime)}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>
|
|
Chats{persisted.length > 0 ? ` · ${persisted.length}` : ""}
|
|
</Text>
|
|
{persisted.length === 0 ? (
|
|
<EmptyChats />
|
|
) : (
|
|
<View style={styles.listCard}>
|
|
{persisted.map((session, index) => (
|
|
<ChatRow
|
|
key={session.path}
|
|
title={chatTitle(session.lastMessage, session.path)}
|
|
meta={`${formatMessageCount(session.messageCount)} · ${formatRelativeDate(session.modified)}`}
|
|
loading={openingPath === session.path}
|
|
isLast={index === persisted.length - 1}
|
|
onPress={() => void resumePersisted(session)}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<Pressable
|
|
style={({ pressed }) => [styles.removeButton, pressed && styles.pressedDim]}
|
|
onPress={() => void handleRemoveProject()}
|
|
>
|
|
<Ionicons name="trash-outline" size={16} color={colors.errorMuted} />
|
|
<Text style={styles.removeButtonText}>Remove project</Text>
|
|
</Pressable>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
scroll: {
|
|
padding: 16,
|
|
gap: 20,
|
|
paddingBottom: 40,
|
|
},
|
|
pressedDim: {
|
|
opacity: 0.7,
|
|
},
|
|
hero: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
paddingHorizontal: 4,
|
|
},
|
|
heroTile: {
|
|
width: 52,
|
|
height: 52,
|
|
borderRadius: 14,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
heroLetter: {
|
|
fontSize: 22,
|
|
fontWeight: "700",
|
|
},
|
|
heroActiveDot: {
|
|
position: "absolute",
|
|
right: -2,
|
|
top: -2,
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: 6,
|
|
backgroundColor: colors.success,
|
|
borderWidth: 2,
|
|
borderColor: colors.background,
|
|
},
|
|
heroText: {
|
|
flex: 1,
|
|
gap: 3,
|
|
},
|
|
heroName: {
|
|
color: colors.text,
|
|
fontSize: 22,
|
|
fontWeight: "700",
|
|
letterSpacing: -0.3,
|
|
},
|
|
heroPath: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
fontFamily: monoFont,
|
|
},
|
|
newChatButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingVertical: 16,
|
|
paddingHorizontal: 16,
|
|
minHeight: 72,
|
|
},
|
|
newChatIcon: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 12,
|
|
backgroundColor: "rgba(255, 255, 255, 0.12)",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
newChatText: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
newChatTitle: {
|
|
color: colors.text,
|
|
fontSize: 17,
|
|
fontWeight: "700",
|
|
},
|
|
newChatHint: {
|
|
color: colors.text,
|
|
fontSize: 13,
|
|
},
|
|
section: {
|
|
gap: 10,
|
|
},
|
|
sectionTitle: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
fontWeight: "700",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
fontFamily: monoFont,
|
|
paddingHorizontal: 4,
|
|
},
|
|
listCard: {
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
overflow: "hidden",
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
paddingLeft: 14,
|
|
},
|
|
rowPressed: {
|
|
backgroundColor: colors.surfaceDeep,
|
|
},
|
|
rowLoading: {
|
|
opacity: 0.7,
|
|
},
|
|
rowIcon: {
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 9,
|
|
backgroundColor: colors.surfaceDeep,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
rowMain: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
paddingVertical: 13,
|
|
paddingRight: 14,
|
|
},
|
|
rowDivider: {
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
rowText: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
rowTitleRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
},
|
|
rowTitle: {
|
|
flex: 1,
|
|
color: colors.text,
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
},
|
|
rowMeta: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
},
|
|
badge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
borderRadius: 6,
|
|
paddingHorizontal: 7,
|
|
paddingVertical: 3,
|
|
backgroundColor: "rgba(74, 222, 128, 0.12)",
|
|
},
|
|
badgeStreaming: {
|
|
backgroundColor: "rgba(251, 191, 36, 0.12)",
|
|
},
|
|
badgeDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
},
|
|
badgeDotActive: {
|
|
backgroundColor: colors.success,
|
|
},
|
|
badgeDotStreaming: {
|
|
backgroundColor: colors.warning,
|
|
},
|
|
badgeText: {
|
|
color: colors.success,
|
|
fontSize: 11,
|
|
fontWeight: "700",
|
|
fontFamily: monoFont,
|
|
},
|
|
badgeTextStreaming: {
|
|
color: colors.warning,
|
|
},
|
|
emptyChats: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingVertical: 20,
|
|
paddingHorizontal: 16,
|
|
},
|
|
emptyChatsText: {
|
|
flex: 1,
|
|
color: colors.textMuted,
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
},
|
|
removeButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
marginTop: 4,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: colors.errorBorder,
|
|
backgroundColor: "rgba(127, 29, 29, 0.2)",
|
|
},
|
|
removeButtonText: {
|
|
color: colors.errorMuted,
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
},
|
|
center: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
muted: {
|
|
color: colors.textMuted,
|
|
fontSize: 15,
|
|
},
|
|
errorBanner: {
|
|
backgroundColor: "#450a0a",
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
},
|
|
errorBannerText: {
|
|
color: colors.errorMuted,
|
|
fontSize: 14,
|
|
},
|
|
});
|