Files
pi-mobile/apps/mobile/app/project.tsx
T
2026-07-09 12:07:57 -05:00

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,
},
});