Files
opencode-mobile/app/(tabs)/index.tsx
Den c9ec92d8b7 feat(demo): add offline demo mode for zero-server activation (#108)
Installers with no self-hosted opencode server hit a dead end at the
empty Sessions state, contributing to ~0% 7-day retention. Adds a
fully offline, scripted /demo route reusing the real chat components
(MessageBubble, ToolCallCard/DiffView, PermissionPrompt) so new users
can see what opencode does before connecting anything, then funnels
them to Connect / the setup guide.

- src/lib/demo-script.ts: pure, hardcoded Message/Part fixture builder
  (no RN/store/network imports) — the isolation guarantee.
- app/demo.tsx: new /demo route rendering the scripted conversation
  via useMemo'd local state only; permission reply is local setState,
  never sessionClient.permission.reply().
- app/(tabs)/index.tsx: "Try a demo" button added to the no-connection
  empty state, placed after the existing add-connection-button so its
  position/testID for existing Maestro flows is unchanged.
- .maestro/flows/demo.yaml: new E2E flow covering the empty-state CTA
  through conversation, diff expand, permission approve, and the CTA
  reaching the real connect form.
- scripts/run-e2e-flows.sh: registers demo in NEWER_FLOWS (non-blocking)
  so it actually runs in CI.
- i18n: new sessionsList.empty.tryDemoButton and demo.* keys added to
  both en.json and zh-Hans.json (catalog-parity verified).

npm run typecheck: clean. npm test: 175/175 passing.

Co-authored-by: engineer <engineer@macbookpro.lan>
2026-07-17 19:04:58 -07:00

1313 lines
42 KiB
TypeScript

import { useCallback, useMemo, useState, useRef, useEffect } from "react"
import {
View,
Text,
FlatList,
ScrollView,
TouchableOpacity,
StyleSheet,
useColorScheme,
RefreshControl,
ActivityIndicator,
Modal,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
Linking,
} from "react-native"
import { router, useFocusEffect } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { useSessions } from "../../src/stores/sessions"
import { useConnections } from "../../src/stores/connections"
import { useEvents } from "../../src/stores/events"
import { useCatalog } from "../../src/stores/catalog"
import type BottomSheet from "@gorhom/bottom-sheet"
import type { Session, Project } from "../../src/lib/sdk"
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
import { groupByDirectory } from "../../src/lib/session-grouping"
import { nameOf } from "../../src/lib/path-utils"
import { SETUP_GUIDE_URL } from "../../src/lib/links"
function formatTime(timestamp: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return t("sessionsList.time.justNow")
if (diff < 3600000) return t("sessionsList.time.minutesAgo", { count: Math.floor(diff / 60000) })
if (diff < 86400000) return t("sessionsList.time.hoursAgo", { count: Math.floor(diff / 3600000) })
if (diff < 604800000) return t("sessionsList.time.daysAgo", { count: Math.floor(diff / 86400000) })
return date.toLocaleDateString()
}
function SessionItem({
session,
isDark,
onRename,
onDelete,
}: {
session: Session
isDark: boolean
onRename: () => void
onDelete: () => void
}) {
const { t } = useTranslation()
const onPress = () => {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
const onLongPress = () => {
Alert.alert(session.title || t("sessionsList.untitledSession"), undefined, [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("sessionsList.actions.rename"), onPress: onRename },
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
])
}
// Extract short directory name from session
const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null
return (
<TouchableOpacity
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
onPress={onPress}
onLongPress={onLongPress}
testID={`session-item-${session.id}`}
>
<View style={styles.sessionContent}>
<View style={styles.sessionHeader}>
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
{session.title || t("sessionsList.untitledSession")}
</Text>
</View>
<View style={styles.sessionMetaRow}>
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
{formatTime(session.time.updated, t)}
{/* summary is always present but files defaults to 0 until the
server populates it — only show the count when it's meaningful,
matching the SessionInfo panel's `summary.files > 0` guard (#55) */}
{session.summary && session.summary.files > 0 &&
` · ${t("sessionsList.filesCount", { count: session.summary.files })}`}
</Text>
{shortDir && (
<View style={styles.sessionDirBadge}>
<Ionicons name="folder-outline" size={12} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.sessionDirText, isDark && styles.metaDark]}>{shortDir}</Text>
</View>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
)
}
// Flattened list row — either a collapsible group header or a session.
// A single flat array keeps FlatList's refresh/empty-state handling as-is
// instead of switching to SectionList.
type ListRow =
| { type: "header"; directory: string; shortName: string; count: number; collapsed: boolean }
| { type: "session"; session: Session }
function GroupHeader({
row,
isDark,
onToggle,
}: {
row: { directory: string; shortName: string; count: number; collapsed: boolean }
isDark: boolean
onToggle: () => void
}) {
return (
<TouchableOpacity
style={[styles.groupHeader, isDark && styles.groupHeaderDark]}
onPress={onToggle}
activeOpacity={0.7}
>
<Ionicons name="folder-outline" size={16} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<Text style={[styles.groupHeaderText, isDark && styles.textDark]} numberOfLines={1}>
{row.shortName}
</Text>
<Text style={[styles.groupHeaderCount, isDark && styles.metaDark]}>{row.count}</Text>
<Ionicons
name={row.collapsed ? "chevron-forward" : "chevron-down"}
size={16}
color={isDark ? "#666666" : "#999999"}
/>
</TouchableOpacity>
)
}
// Get short directory name (last folder or project name)
function getShortPath(
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
): string {
if (!project) return ""
if (project.name) return project.name
if (!project.path?.absolute) return ""
const parts = project.path.absolute.split("/").filter(Boolean)
return parts[parts.length - 1] || project.path.absolute
}
export default function SessionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
const [showNewSession, setShowNewSession] = useState(false)
const [customDir, setCustomDir] = useState("")
const [isCreating, setIsCreating] = useState(false)
const [renaming, setRenaming] = useState<Session | null>(null)
const [renameText, setRenameText] = useState("")
const renamingInFlight = useRef(false)
const [serverProjects, setServerProjects] = useState<Project[]>([])
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
const {
activeConnection,
client,
currentProject,
serverHome,
refreshProject,
clientForDirectory,
switchDirectory,
addRecentDirectory,
recentDirectories,
} = useConnections()
const authError = useEvents((s) => s.authError)
const reconnect = useEvents((s) => s.connect)
const loadCatalog = useCatalog((s) => s.load)
const dirSheetRef = useRef<BottomSheet>(null)
const browserSheetRef = useRef<BottomSheet>(null)
const [browseStartDir, setBrowseStartDir] = useState<string | null>(null)
// Shared folder browser is opened either to pick a directory for a new
// session, or to switch the active connection's directory.
const [browseMode, setBrowseMode] = useState<"create" | "switch">("create")
const [refreshing, setRefreshing] = useState(false)
// Directories collapsed in the grouped session list. Empty by default —
// all groups start expanded (#67).
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
const toggleGroup = useCallback((directory: string) => {
setCollapsedDirs((prev) => {
const next = new Set(prev)
if (next.has(directory)) next.delete(directory)
else next.add(directory)
return next
})
}, [])
// Flatten sessions into header+item rows. Skip headers entirely when
// everything lives in one directory — a lone header adds noise, not clarity.
const rows = useMemo<ListRow[]>(() => {
const groups = groupByDirectory(sessions)
if (groups.length <= 1) {
return sessions.map((session) => ({ type: "session", session }))
}
const out: ListRow[] = []
for (const group of groups) {
const collapsed = collapsedDirs.has(group.directory)
out.push({
type: "header",
directory: group.directory,
shortName: nameOf(group.directory) || group.directory,
count: group.items.length,
collapsed,
})
if (!collapsed) {
for (const session of group.items) out.push({ type: "session", session })
}
}
return out
}, [sessions, collapsedDirs])
// Fetch server-known projects when the new session modal opens
useEffect(() => {
if (!showNewSession || !client) return
client.project
.list()
.then(setServerProjects)
.catch(() => setServerProjects([]))
}, [showNewSession, client])
const handleSwitchDirectory = useCallback(
async (dir?: string) => {
await switchDirectory(dir)
loadSessions()
refreshProject()
loadCatalog()
},
[switchDirectory, loadSessions, refreshProject, loadCatalog],
)
useFocusEffect(
useCallback(() => {
if (client) {
loadSessions()
refreshProject()
}
}, [client, loadSessions, refreshProject]),
)
const onRefresh = useCallback(async () => {
setRefreshing(true)
try {
await Promise.all([loadSessions(), refreshProject()])
} catch (err) {
console.error("Refresh failed:", err)
} finally {
setRefreshing(false)
}
}, [loadSessions, refreshProject])
const handleRename = useCallback((session: Session) => {
setRenameText(session.title || "")
setRenaming(session)
}, [])
const submitRename = useCallback(async () => {
const title = renameText.trim()
if (!title || !renaming || renamingInFlight.current) return
const renameClient = renaming.directory ? (clientForDirectory(renaming.directory) ?? client) : client
if (!renameClient) return
renamingInFlight.current = true
try {
await renameClient.session.update(renaming.id, { title })
setRenaming(null)
setRenameText("")
loadSessions()
} catch (err) {
console.error("Rename failed:", err)
Alert.alert(t("sessionsList.alerts.renameFailedTitle"), t("sessionsList.alerts.renameFailedMessage"))
} finally {
renamingInFlight.current = false
}
}, [renaming, renameText, client, clientForDirectory, loadSessions, t])
const handleDelete = useCallback(
(session: Session) => {
Alert.alert(
t("sessionsList.alerts.deleteTitle"),
t("sessionsList.alerts.deleteMessage", { title: session.title || t("sessionsList.untitledSession") }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
try {
await deleteSession(session.id)
} catch (err) {
console.error("Delete failed:", err)
Alert.alert(t("sessionsList.alerts.deleteFailedTitle"), t("sessionsList.alerts.deleteFailedMessage"))
}
},
},
],
)
},
[deleteSession, t],
)
const onCreateSession = async () => {
const session = await createSession()
if (session) {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
}
const onCreateInDirectory = async (dir?: string) => {
if (!activeConnection) return
setIsCreating(true)
// If a custom directory is specified, use a one-off client for that directory
// so we don't mutate the connection's default project
if (dir && dir.trim()) {
const dirClient = clientForDirectory(dir.trim())
if (!dirClient) {
setIsCreating(false)
return
}
try {
const session = await dirClient.session.create({})
addRecentDirectory(dir.trim())
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
} catch (error) {
console.error("Failed to create session in directory:", error)
Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage"))
setIsCreating(false)
}
return
}
const session = await createSession()
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push({
pathname: `/session/[id]`,
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
})
}
}
// The browser sheet is a sibling of the New Session <Modal>. A native RN
// Modal layers above everything in the React root (including bottom-sheet
// portals), so the modal must be closed before the sheet is shown; this ref
// remembers to bring it back if the user cancels without picking a folder.
const restoreNewSessionOnDismiss = useRef(false)
const openBrowser = useCallback(
(startDir: string | null, mode: "create" | "switch") => {
setBrowseStartDir(startDir || serverHome || null)
setBrowseMode(mode)
if (mode === "create" && showNewSession) {
restoreNewSessionOnDismiss.current = true
setShowNewSession(false)
}
browserSheetRef.current?.expand()
},
[serverHome, showNewSession],
)
const onBrowserSelect = useCallback(
(directory: string) => {
restoreNewSessionOnDismiss.current = false
if (browseMode === "switch") {
handleSwitchDirectory(directory)
dirSheetRef.current?.close()
} else {
onCreateInDirectory(directory)
}
},
[browseMode, handleSwitchDirectory, onCreateInDirectory],
)
const onBrowserDismiss = useCallback(() => {
if (restoreNewSessionOnDismiss.current) {
restoreNewSessionOnDismiss.current = false
setShowNewSession(true)
}
}, [])
const onFabPress = () => {
// Quick create in current project
onCreateSession()
}
const onFabLongPress = () => {
// Show modal with more options
setCustomDir("")
setShowNewSession(true)
}
if (!activeConnection) {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.noConnectionTitle")}</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
{t("sessionsList.empty.noConnectionSubtitle")}
</Text>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => router.push("/connection/add")}
testID="add-connection-button"
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>
{t("sessionsList.empty.addConnectionButton")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.setupGuideLink}
onPress={() => Linking.openURL(SETUP_GUIDE_URL)}
testID="setup-guide-link"
>
<Text style={styles.setupGuideLinkText}>{t("sessionsList.empty.setupGuideLink")}</Text>
</TouchableOpacity>
{/* No-server activation path (retention): a fully offline scripted
demo, isolated from real connect/session state — see app/demo.tsx. */}
<TouchableOpacity
style={[styles.tryDemoButton, isDark && styles.tryDemoButtonDark]}
onPress={() => router.push("/demo")}
testID="try-demo-button"
>
<Ionicons name="play-circle-outline" size={16} color={isDark ? "#a78bfa" : "#6d28d9"} />
<Text style={[styles.tryDemoButtonText, isDark && styles.tryDemoButtonTextDark]}>
{t("sessionsList.empty.tryDemoButton")}
</Text>
</TouchableOpacity>
</View>
)
}
// The SSE loop stopped retrying because the server rejected our
// credentials (401/403) — no amount of pull-to-refresh fixes that, so
// point the user straight at the fix instead of a spinner that never
// resolves (issue #76).
if (authError) {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<Ionicons name="lock-closed-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.authFailedTitle")}</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
{t("sessionsList.empty.authFailedSubtitle", { name: activeConnection.name })}
</Text>
<View style={styles.authErrorButtonRow}>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => router.push(`/connection/${activeConnection.id}`)}
testID="fix-connection-button"
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>
{t("sessionsList.empty.checkCredentialsButton")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => {
// authError is cleared inside connect() itself once the retry
// attempt starts (see src/stores/events.ts), so a manual
// set() here isn't needed — just kick the SSE state machine.
reconnect()
}}
testID="retry-connection-button"
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>{t("common.retry")}</Text>
</TouchableOpacity>
</View>
</View>
)
}
const shortPath = getShortPath(currentProject)
return (
<View style={[styles.container, isDark && styles.containerDark]}>
{/* Connection indicator — tap to switch project */}
<TouchableOpacity
style={[styles.connectionBar, isDark && styles.connectionBarDark]}
onPress={() => dirSheetRef.current?.expand()}
onLongPress={() => router.push("/(tabs)/connections")}
activeOpacity={0.7}
testID="connection-status-bar"
>
<View style={styles.connectionInfo}>
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} testID="connection-status-dot" />
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
{activeConnection.name}
</Text>
{shortPath && (
<>
<Ionicons name="folder" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.projectPath, isDark && styles.metaDark]} numberOfLines={1}>
{shortPath}
</Text>
</>
)}
</View>
<Ionicons name="swap-horizontal-outline" size={16} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
{error && (
<View style={styles.errorBar}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<FlatList
data={rows}
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
renderItem={({ item: row }) =>
row.type === "header" ? (
<GroupHeader row={row} isDark={isDark} onToggle={() => toggleGroup(row.directory)} />
) : (
<SessionItem
session={row.session}
isDark={isDark}
onRename={() => handleRename(row.session)}
onDelete={() => handleDelete(row.session)}
/>
)
}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
}
ListEmptyComponent={
isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : (
<View style={styles.emptyList}>
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>{t("sessionsList.empty.noSessions")}</Text>
</View>
)
}
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
/>
{/* FAB to create new session */}
<TouchableOpacity
style={[styles.fab, isDark && styles.fabDark]}
onPress={onFabPress}
onLongPress={onFabLongPress}
delayLongPress={500}
testID="new-session-fab"
>
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
{/* New Session Info Modal */}
<Modal visible={showNewSession} animationType="slide" transparent>
<KeyboardAvoidingView style={styles.modalOverlay} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, isDark && styles.textDark]}>{t("sessionsList.newSessionModal.title")}</Text>
<TouchableOpacity onPress={() => setShowNewSession(false)}>
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalScrollBody} keyboardShouldPersistTaps="handled">
{/* Current directory — tapping creates session immediately */}
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>
{t("sessionsList.newSessionModal.currentProjectLabel")}
</Text>
<TouchableOpacity
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
onPress={() => onCreateInDirectory()}
disabled={isCreating}
>
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
{currentProject?.path?.absolute || activeConnection?.directory || t("sessionsList.newSessionModal.serverDefault")}
</Text>
<Ionicons name="arrow-forward-circle" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
</TouchableOpacity>
{/* Recent projects */}
{recentDirectories.length > 0 && (
<>
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
{t("sessionsList.newSessionModal.recentProjectsLabel")}
</Text>
{recentDirectories.map((dir) => {
const short = dir.split("/").filter(Boolean).pop() || dir
const isCurrent =
dir === (currentProject?.path?.absolute || activeConnection?.directory)
return (
<TouchableOpacity
key={dir}
style={[
styles.projectRow,
isDark && styles.projectRowDark,
isCurrent && styles.projectRowActive,
]}
onPress={() => onCreateInDirectory(dir)}
disabled={isCreating}
>
<Ionicons
name="folder-outline"
size={18}
color={isCurrent ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
/>
<View style={styles.projectRowContent}>
<Text
style={[
styles.projectRowName,
isDark && styles.textDark,
isCurrent && styles.projectRowNameActive,
]}
numberOfLines={1}
>
{short}
</Text>
<Text style={[styles.projectRowPath, isDark && styles.metaDark]} numberOfLines={1}>
{dir}
</Text>
</View>
{isCurrent && <Ionicons name="checkmark-circle" size={18} color="#8b5cf6" />}
</TouchableOpacity>
)
})}
</>
)}
{/* Server-known projects (excluding current) */}
{serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && (
<>
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
{t("sessionsList.newSessionModal.serverProjectsLabel")}
</Text>
{serverProjects
.filter((p) => p.path?.absolute !== currentProject?.path?.absolute)
.map((p) => {
const short = p.name || p.path?.absolute?.split("/").filter(Boolean).pop() || p.id
return (
<TouchableOpacity
key={p.id}
style={[styles.projectRow, isDark && styles.projectRowDark]}
onPress={() => onCreateInDirectory(p.path?.absolute)}
disabled={isCreating}
>
<Ionicons name="code-slash-outline" size={18} color={isDark ? "#888888" : "#666666"} />
<View style={styles.projectRowContent}>
<Text style={[styles.projectRowName, isDark && styles.textDark]} numberOfLines={1}>
{short}
</Text>
{p.path?.absolute && (
<Text style={[styles.projectRowPath, isDark && styles.metaDark]} numberOfLines={1}>
{p.path.absolute}
</Text>
)}
</View>
</TouchableOpacity>
)
})}
</>
)}
{/* Browse the server's filesystem instead of typing a path */}
<TouchableOpacity
style={[styles.projectRow, isDark && styles.projectRowDark, { marginTop: 16 }]}
onPress={() =>
openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create")
}
disabled={isCreating}
testID="browse-folders-button"
>
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<View style={styles.projectRowContent}>
<Text style={[styles.projectRowName, isDark && styles.textDark]}>
{t("sessionsList.newSessionModal.browseFoldersLabel")}
</Text>
<Text style={[styles.projectRowPath, isDark && styles.metaDark]}>
{t("sessionsList.newSessionModal.browseFoldersHint")}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
{/* Manual path input fallback */}
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
{t("sessionsList.newSessionModal.enterPathLabel")}
</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={customDir}
onChangeText={(text) => {
// Expand ~ to server home directory
if (serverHome && text.startsWith("~/")) {
setCustomDir(serverHome + text.slice(1))
} else if (serverHome && text === "~") {
setCustomDir(serverHome)
} else {
setCustomDir(text)
}
}}
autoCapitalize="none"
autoCorrect={false}
/>
{/* Quick path shortcuts */}
{serverHome && (
<View style={styles.pathChips}>
<TouchableOpacity
style={[styles.pathChip, isDark && styles.pathChipDark]}
onPress={() => setCustomDir(serverHome)}
>
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.pathChip, isDark && styles.pathChipDark]}
onPress={() => setCustomDir(serverHome + "/")}
>
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~/</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
<View style={styles.modalActions}>
{customDir.trim() ? (
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButtonPrimary,
isDark && styles.modalButtonPrimaryDark,
styles.modalButtonFull,
]}
onPress={() => onCreateInDirectory(customDir)}
disabled={isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
{t("sessionsList.newSessionModal.createInButton", {
dir: customDir.split("/").filter(Boolean).pop() || customDir,
})}
</Text>
)}
</TouchableOpacity>
) : (
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButtonPrimary,
isDark && styles.modalButtonPrimaryDark,
styles.modalButtonFull,
]}
onPress={() => onCreateInDirectory()}
disabled={isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
{t("sessionsList.newSessionModal.createSessionButton")}
</Text>
)}
</TouchableOpacity>
)}
</View>
</View>
</KeyboardAvoidingView>
</Modal>
{/* Rename modal */}
<Modal visible={!!renaming} animationType="fade" transparent>
<KeyboardAvoidingView
style={[styles.modalOverlay, { justifyContent: "center" }]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
<Text style={[styles.renameTitle, isDark && styles.textDark]}>{t("sessionsList.renameModal.title")}</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
value={renameText}
onChangeText={setRenameText}
onSubmitEditing={submitRename}
returnKeyType="done"
autoFocus
selectTextOnFocus
autoCapitalize="sentences"
autoCorrect={false}
/>
<View style={styles.renameActions}>
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
<Text style={styles.renameBtnCancelText}>{t("common.cancel")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
onPress={submitRename}
disabled={!renameText.trim()}
>
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
{t("common.save")}
</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
</KeyboardAvoidingView>
</Modal>
{/* Directory switcher bottom sheet */}
<DirectorySwitcher
sheetRef={dirSheetRef}
current={activeConnection?.directory}
recents={recentDirectories}
serverHome={serverHome}
isDark={isDark}
onSwitch={handleSwitchDirectory}
onBrowse={() =>
openBrowser(activeConnection?.directory || currentProject?.path?.absolute || null, "switch")
}
/>
{/* Browsable folder picker — used for both "new session in..." and
"switch project directory" flows (see browseMode). */}
<DirectoryBrowserSheet
sheetRef={browserSheetRef}
startDirectory={browseStartDir}
clientForDirectory={clientForDirectory}
isDark={isDark}
onSelect={onBrowserSelect}
onDismiss={onBrowserDismiss}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
connectionBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
connectionBarDark: {
borderBottomColor: "#1a1a1a",
},
connectionInfo: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
connectionDot: {
width: 8,
height: 8,
borderRadius: 4,
},
connectionName: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
},
connectionUrl: {
fontSize: 12,
color: "#666666",
},
projectPath: {
fontSize: 13,
color: "#666666",
flex: 1,
},
errorBar: {
backgroundColor: "#fef2f2",
padding: 12,
borderBottomWidth: 1,
borderBottomColor: "#fecaca",
},
errorText: {
color: "#dc2626",
fontSize: 14,
},
groupHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "#f5f5f5",
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
groupHeaderDark: {
backgroundColor: "#151515",
borderBottomColor: "#1a1a1a",
},
groupHeaderText: {
flex: 1,
fontSize: 13,
fontWeight: "600",
color: "#0a0a0a",
},
groupHeaderCount: {
fontSize: 12,
color: "#666666",
},
sessionItem: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
sessionItemDark: {
borderBottomColor: "#1a1a1a",
},
sessionContent: {
flex: 1,
},
sessionHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 2,
},
sessionTitle: {
fontSize: 16,
fontWeight: "500",
color: "#0a0a0a",
marginBottom: 4,
},
textDark: {
color: "#ffffff",
},
sessionMeta: {
fontSize: 13,
color: "#666666",
},
sessionMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
sessionDirBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
sessionDirText: {
fontSize: 11,
color: "#666666",
},
metaDark: {
color: "#888888",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
backgroundColor: "#ffffff",
},
emptyTitle: {
fontSize: 20,
fontWeight: "600",
marginTop: 16,
color: "#0a0a0a",
},
emptySubtitle: {
fontSize: 14,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
addButton: {
backgroundColor: "#0a0a0a",
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
marginTop: 24,
},
authErrorButtonRow: {
flexDirection: "row",
gap: 12,
},
addButtonDark: {
backgroundColor: "#ffffff",
},
addButtonText: {
color: "#ffffff",
fontWeight: "600",
},
addButtonTextDark: {
color: "#0a0a0a",
},
setupGuideLink: {
marginTop: 16,
},
setupGuideLinkText: {
fontSize: 14,
fontWeight: "600",
color: "#6366f1",
},
tryDemoButton: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: 20,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1,
borderColor: "#8b5cf6",
},
tryDemoButtonDark: {
borderColor: "#a78bfa",
},
tryDemoButtonText: {
fontSize: 14,
fontWeight: "600",
color: "#6d28d9",
},
tryDemoButtonTextDark: {
color: "#a78bfa",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
emptyList: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
emptyListText: {
fontSize: 16,
color: "#666666",
},
emptyContent: {
flex: 1,
},
fab: {
position: "absolute",
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
fabDark: {
backgroundColor: "#ffffff",
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalDismiss: {
flex: 1,
},
modalContent: {
backgroundColor: "#ffffff",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
paddingBottom: 40,
},
modalContentDark: {
backgroundColor: "#1a1a1a",
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
color: "#0a0a0a",
},
modalBody: {
marginBottom: 24,
},
modalScrollBody: {
maxHeight: 420,
marginBottom: 16,
},
projectRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingHorizontal: 14,
paddingVertical: 11,
borderRadius: 10,
backgroundColor: "#f5f5f5",
marginBottom: 6,
},
projectRowDark: {
backgroundColor: "#2a2a2a",
},
projectRowActive: {
backgroundColor: "#f5f3ff",
},
projectRowContent: {
flex: 1,
},
projectRowName: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
},
projectRowNameActive: {
color: "#8b5cf6",
},
projectRowPath: {
fontSize: 11,
color: "#999999",
marginTop: 1,
},
modalLabel: {
fontSize: 13,
fontWeight: "600",
color: "#666666",
marginBottom: 8,
textTransform: "uppercase",
},
modalDirBox: {
flexDirection: "row",
alignItems: "center",
gap: 12,
backgroundColor: "#f5f5f5",
padding: 16,
borderRadius: 12,
},
modalDirBoxDark: {
backgroundColor: "#2a2a2a",
},
modalDirText: {
fontSize: 15,
color: "#0a0a0a",
flex: 1,
},
modalInput: {
backgroundColor: "#f5f5f5",
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
color: "#0a0a0a",
},
modalInputDark: {
backgroundColor: "#2a2a2a",
color: "#ffffff",
},
pathChips: {
flexDirection: "row",
gap: 8,
marginTop: 8,
},
pathChip: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e5f0",
borderRadius: 16,
},
pathChipDark: {
backgroundColor: "#2a2040",
},
pathChipText: {
fontSize: 13,
fontWeight: "600",
color: "#6d28d9",
},
pathChipTextDark: {
color: "#c4b5fd",
},
modalHint: {
fontSize: 13,
color: "#666666",
marginTop: 12,
},
modalActions: {
flexDirection: "row",
gap: 12,
},
modalButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
borderRadius: 12,
},
modalButtonSecondary: {
backgroundColor: "#f5f5f5",
},
modalButtonSecondaryDark: {
backgroundColor: "#2a2a2a",
},
modalButtonPrimary: {
backgroundColor: "#0a0a0a",
},
modalButtonPrimaryDark: {
backgroundColor: "#ffffff",
},
modalButtonText: {
fontSize: 15,
fontWeight: "600",
color: "#0a0a0a",
},
modalButtonTextPrimary: {
fontSize: 15,
fontWeight: "600",
color: "#ffffff",
},
modalButtonTextPrimaryDark: {
color: "#0a0a0a",
},
modalButtonFull: {
flex: 0,
width: "100%",
},
// Rename modal
renameCard: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 20,
marginHorizontal: 32,
gap: 16,
},
renameCardDark: {
backgroundColor: "#1a1a1a",
},
renameTitle: {
fontSize: 17,
fontWeight: "600",
color: "#0a0a0a",
},
renameActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 12,
},
renameBtn: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
renameBtnCancel: {
backgroundColor: "transparent",
},
renameBtnCancelText: {
fontSize: 15,
fontWeight: "600",
color: "#888888",
},
})