feat(file-explorer): add parallel thumbnail loading and file preview

- Implement parallel thumbnail queue (2 concurrent) for gallery view
- Add BottomSheetModal file preview with 80% snap point
- Add pull-to-refresh for directory listings
- Fix image loading race conditions with Set-based in-flight tracking

Also includes:
- message-input: add Cmd+Shift+D dictation toggle shortcut
- message: update user message bubble to darker zinc color
- Add PRODUCTION.md deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-01-05 08:41:43 +07:00
parent edb3258ae9
commit 9d7e44851c
4 changed files with 749 additions and 158 deletions

313
PRODUCTION.md Normal file
View File

@@ -0,0 +1,313 @@
# Production Architecture
## Overview
Paseo ships as a headless daemon distributed via npm, with native apps (iOS/Android) and a bundled web UI. All communication is end-to-end encrypted. By default, the daemon connects to Paseo Link for remote access.
## Distribution
```bash
npm install -g @paseo/daemon
paseo start
```
The daemon package includes the web UI bundle, served at `http://localhost:6767`.
### Package Structure
```
@paseo/daemon
├── dist/
│ ├── cli.js # Entry point
│ ├── server/ # Daemon code
│ └── public/ # Bundled web UI from @paseo/app
```
### CLI Commands
```bash
paseo start # Run in foreground, connect to Link
paseo start --daemon # Run in background
paseo start --no-link # Direct connections only (for VPN/Tailscale users)
paseo stop # Stop background daemon
paseo pair # Generate pairing code
paseo devices # List paired devices
paseo revoke <id> # Remove a paired device
```
## Security Model
### Threat Model
Pairing protects against:
- Malicious JS on websites trying to connect to localhost
- Network attackers (misconfigured firewall, shared network)
- Link server snooping (E2EE - it only sees encrypted bytes)
- Unauthorized local network users
Not protected (out of scope):
- Malicious processes running as your user (already have full access)
### Device Pairing
Every device must pair with the daemon before communicating. Pairing is a one-time event per device.
**Flow:**
1. User runs `paseo pair`
2. Daemon generates one-time token (e.g., `K3X9-M2B7`), held in memory
3. Token displayed as QR code + plaintext
4. Client connects and presents token
5. ECDH key exchange → shared secret derived
6. Token invalidated, device stored as paired
7. All future communication encrypted with derived key
**Token format:**
- 8 alphanumeric characters (no ambiguous chars: 0/O, 1/l/I)
- Expires after 5 minutes or single use
- Example: `K3X9-M2B7`
**Stored per device:**
```typescript
interface PairedDevice {
id: string
name: string // "Mohamed's iPhone"
publicKey: string
pairedAt: Date
lastSeen: Date
}
```
### End-to-End Encryption
All communication is E2EE, regardless of transport:
```
┌─────────────────────────────────────────────────┐
│ Transport │
│ (direct WS / Link / Tailscale / LAN) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ E2EE Layer (always on) │
│ Pairing token → key exchange │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Application Protocol │
└─────────────────────────────────────────────────┘
```
## Connectivity
### Paseo Link (default)
Remote access via `link.paseo.sh`:
```
Phone ──WS──▶ Cloudflare Edge ──▶ Durable Object (daemon-xyz)
Daemon ──WS──▶ Cloudflare Edge ──────────┘
```
**Link properties:**
- Dumb pipe - forwards encrypted bytes only
- Cannot read traffic (E2EE)
- Stateful via Cloudflare Durable Objects
- Both daemon and clients routed to same DO instance by daemon ID
- Enabled by default, disable with `--no-link`
### Direct Access
For local network or VPN (Tailscale, etc.):
- Client connects directly to daemon WebSocket
- E2EE still required (paired device presents public key)
- No Link involved
- Use `paseo start --no-link` if you only want direct connections
**Pairing URL format:**
```
paseo://<daemon-id>@<link-or-direct-addr>?token=<one-time-pairing-token>
```
## Build & Bundling
### Web UI Bundling
The web UI is built from `@paseo/app` and copied into the daemon's dist:
```json
{
"scripts": {
"build:app": "npm run build --workspace=@paseo/app -- --platform web",
"build:server": "tsc && cp -r ../app/dist/web ./dist/public",
"build": "npm run build:app && npm run build:server"
}
}
```
### Server Static Serving
```typescript
import express from 'express'
import path from 'path'
const app = express()
// API routes
app.use('/api', apiRouter)
// Static files - bundled web app
app.use(express.static(path.join(__dirname, 'public')))
// SPA fallback
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'))
})
```
## Daemon Process Management
### Background Mode
```typescript
import { spawn } from 'child_process'
import fs from 'fs'
if (command === 'start' && process.argv.includes('--daemon')) {
const child = spawn(process.execPath, [__filename, 'start'], {
detached: true,
stdio: 'ignore',
})
child.unref()
fs.writeFileSync('~/.paseo/daemon.pid', child.pid.toString())
console.log(`Daemon started (pid: ${child.pid})`)
process.exit(0)
}
if (command === 'stop') {
const pid = fs.readFileSync('~/.paseo/daemon.pid', 'utf-8')
process.kill(parseInt(pid))
fs.unlinkSync('~/.paseo/daemon.pid')
console.log('Daemon stopped')
}
```
### System Service (optional)
For auto-start on boot, users can install a system service:
**macOS (launchd):**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.paseo.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/paseo</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
```
**Linux (systemd):**
```ini
[Unit]
Description=Paseo Daemon
After=network.target
[Service]
ExecStart=/usr/local/bin/paseo start
Restart=always
User=%u
[Install]
WantedBy=default.target
```
## Paseo Link Server (Cloudflare)
### Durable Object Implementation
```typescript
export class LinkRoom extends DurableObject {
daemon: WebSocket | null = null
clients: Map<string, WebSocket> = new Map()
async fetch(req: Request) {
const upgradeHeader = req.headers.get('Upgrade')
if (upgradeHeader !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 })
}
const [client, server] = Object.values(new WebSocketPair())
const role = req.headers.get('x-paseo-role')
const clientId = req.headers.get('x-paseo-client-id')
server.accept()
if (role === 'daemon') {
this.daemon = server
server.addEventListener('message', (e) => {
// Forward to all clients (they decrypt what's theirs)
this.clients.forEach(c => c.send(e.data))
})
} else {
this.clients.set(clientId, server)
server.addEventListener('message', (e) => {
this.daemon?.send(e.data)
})
server.addEventListener('close', () => {
this.clients.delete(clientId)
})
}
return new Response(null, { status: 101, webSocket: client })
}
}
```
### Worker Entry Point
```typescript
export default {
async fetch(req: Request, env: Env) {
const url = new URL(req.url)
const daemonId = url.pathname.split('/')[2] // /link/<daemon-id>
const id = env.LINK_ROOMS.idFromName(daemonId)
const room = env.LINK_ROOMS.get(id)
return room.fetch(req)
}
}
```
### Pricing Estimate
- Durable Objects: $0.15/million requests
- WebSocket messages count as requests
- Storage: $0.15/GB-month (minimal for Link)
- Expected cost for personal use: < $1/month
## Future Enhancements
- **Homebrew formula** for easier Mac installation
- **Docker image** for server deployments
- **Bundled Node binary** (pkg/nexe) for non-Node users
- **Auto-update mechanism** via npm or custom updater

View File

@@ -6,6 +6,7 @@ import {
Image as RNImage,
LayoutChangeEvent,
ListRenderItemInfo,
RefreshControl,
ViewToken,
NativeScrollEvent,
NativeSyntheticEvent,
@@ -23,6 +24,12 @@ import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import * as Clipboard from "expo-clipboard";
import * as FileSystem from "expo-file-system";
import * as Sharing from "expo-sharing";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
File,
FileText,
@@ -173,43 +180,43 @@ function FileExplorerContent({
const showInitialListLoading = isListingLoading && entries.length === 0;
const showListLoadingBanner = isListingLoading && entries.length > 0;
const isPreviewLoading = Boolean(
isExplorerLoading && pendingRequest?.mode === "file"
isExplorerLoading &&
pendingRequest?.mode === "file" &&
pendingRequest?.path === selectedEntryPath
);
const error = explorerState?.lastError ?? null;
const preview = selectedEntryPath
? explorerState?.files.get(selectedEntryPath)
: null;
const shouldShowPreview = Boolean(selectedEntryPath);
const pendingThumbnailPathsRef = useRef<Set<string>>(new Set());
const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState<Record<string, boolean>>({});
const viewabilityConfigRef = useRef({ itemVisiblePercentThreshold: 50 });
const gridColumnCount = useMemo(() => {
if (windowWidth >= 1500) {
return 6;
}
if (windowWidth >= 1200) {
return 5;
}
if (windowWidth >= 960) {
return 4;
}
if (windowWidth >= 720) {
return 3;
}
if (windowWidth >= 520) {
return 2;
}
return 1;
}, [windowWidth]);
const viewabilityConfigRef = useRef({
itemVisiblePercentThreshold: 10,
minimumViewTime: 0,
});
// Bottom sheet for file preview
const previewSheetRef = useRef<BottomSheetModal>(null);
const previewSnapPoints = useMemo(() => ["80%"], []);
// Thumbnail queue state - allows up to MAX_CONCURRENT_THUMBNAILS in parallel
const MAX_CONCURRENT_THUMBNAILS = 2;
const thumbnailQueueRef = useRef<string[]>([]);
const inFlightPathsRef = useRef<Set<string>>(new Set());
const THUMBNAIL_TIMEOUT_MS = 15000;
const gridColumnCount = 2;
const listColumns = viewMode === "grid" ? gridColumnCount : 1;
const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list";
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
const [menuHeight, setMenuHeight] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const agentIdRef = useRef(agentId);
const viewModeRef = useRef(viewMode);
const requestFilePreviewRef = useRef(requestFilePreview);
const explorerFilesRef = useRef(explorerState?.files);
const refreshPathRef = useRef<string | null>(null);
const refreshStartedRef = useRef(false);
useEffect(() => {
agentIdRef.current = agentId;
@@ -227,37 +234,137 @@ function FileExplorerContent({
explorerFilesRef.current = explorerState?.files;
}, [explorerState?.files]);
// Process items from the thumbnail queue (up to MAX_CONCURRENT_THUMBNAILS in parallel)
const processNextThumbnail = useCallback(() => {
const currentAgentId = agentIdRef.current;
const currentRequestFilePreview = requestFilePreviewRef.current;
if (!currentAgentId || !currentRequestFilePreview) {
return;
}
// Fill up to max concurrent slots
while (
inFlightPathsRef.current.size < MAX_CONCURRENT_THUMBNAILS &&
thumbnailQueueRef.current.length > 0
) {
const path = thumbnailQueueRef.current.shift()!;
// Skip if already loaded or already in flight
if (explorerFilesRef.current?.has(path) || inFlightPathsRef.current.has(path)) {
continue;
}
inFlightPathsRef.current.add(path);
setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true }));
currentRequestFilePreview(currentAgentId, path);
// Set up timeout to clean up stuck requests
setTimeout(() => {
if (inFlightPathsRef.current.has(path)) {
inFlightPathsRef.current.delete(path);
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
delete next[path];
return next;
});
processNextThumbnail();
}
}, THUMBNAIL_TIMEOUT_MS);
}
}, []);
// Enqueue a file preview request with optional priority
const enqueueFilePreview = useCallback(
(path: string, options?: { priority?: boolean }) => {
const currentAgentId = agentIdRef.current;
const currentRequestFilePreview = requestFilePreviewRef.current;
if (!currentAgentId || !currentRequestFilePreview) {
return;
}
// Already have this file cached
if (explorerFilesRef.current?.has(path)) {
return;
}
if (options?.priority) {
// Priority request: clear queue entirely
thumbnailQueueRef.current = [];
// If this path is already in flight, let it complete
if (inFlightPathsRef.current.has(path)) {
return;
}
// Clear all in-flight thumbnails (their timeouts will clean up loading state)
if (inFlightPathsRef.current.size > 0) {
const abandonedPaths = Array.from(inFlightPathsRef.current);
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
abandonedPaths.forEach((p) => delete next[p]);
return next;
});
inFlightPathsRef.current.clear();
}
// Fire immediately for priority requests
inFlightPathsRef.current.add(path);
setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true }));
currentRequestFilePreview(currentAgentId, path);
// Set up timeout for priority requests too
setTimeout(() => {
if (inFlightPathsRef.current.has(path)) {
inFlightPathsRef.current.delete(path);
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
delete next[path];
return next;
});
processNextThumbnail();
}
}, THUMBNAIL_TIMEOUT_MS);
return;
}
// Non-priority: add to queue if not already queued or in-flight
if (
!thumbnailQueueRef.current.includes(path) &&
!inFlightPathsRef.current.has(path)
) {
thumbnailQueueRef.current.push(path);
processNextThumbnail();
}
},
[processNextThumbnail]
);
const handleViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
const currentAgentId = agentIdRef.current;
const currentViewMode = viewModeRef.current;
const currentRequestFilePreview = requestFilePreviewRef.current;
if (!currentAgentId || currentViewMode !== "grid" || !currentRequestFilePreview) {
if (currentViewMode !== "grid") {
return;
}
viewableItems.forEach((token) => {
const item = token.item as ExplorerEntry | undefined;
if (!item) {
if (!item || getEntryDisplayKind(item) !== "image") {
return;
}
if (getEntryDisplayKind(item) !== "image") {
return;
}
const hasPreview = explorerFilesRef.current?.get(item.path);
if (hasPreview || pendingThumbnailPathsRef.current.has(item.path)) {
return;
}
pendingThumbnailPathsRef.current.add(item.path);
setThumbnailLoadingMap((prev) => ({ ...prev, [item.path]: true }));
currentRequestFilePreview(currentAgentId, item.path);
enqueueFilePreviewRef.current?.(item.path);
});
}
);
const enqueueFilePreviewRef = useRef(enqueueFilePreview);
useEffect(() => {
enqueueFilePreviewRef.current = enqueueFilePreview;
}, [enqueueFilePreview]);
const restoreQueuedScrollOffset = useCallback(() => {
if (pendingScrollRestoreRef.current === null) {
return;
@@ -282,6 +389,15 @@ function FileExplorerContent({
setSelectedEntryPath(null);
}, [activePath]);
// Open/close preview sheet based on selection
useEffect(() => {
if (selectedEntryPath) {
previewSheetRef.current?.present();
} else {
previewSheetRef.current?.dismiss();
}
}, [selectedEntryPath]);
useEffect(() => {
if (shouldShowPreview) {
return;
@@ -312,14 +428,14 @@ function FileExplorerContent({
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
useEffect(() => {
if (!agentId || !normalizedFileParam || !requestFilePreview) {
if (!agentId || !normalizedFileParam) {
pendingFileParamRef.current = null;
return;
}
pendingFileParamRef.current = normalizedFileParam;
requestFilePreview(agentId, normalizedFileParam);
}, [agentId, normalizedFileParam, requestFilePreview]);
enqueueFilePreview(normalizedFileParam, { priority: true });
}, [agentId, normalizedFileParam, enqueueFilePreview]);
useEffect(() => {
if (!agentId) {
@@ -342,7 +458,7 @@ function FileExplorerContent({
const handleEntryPress = useCallback(
(entry: ExplorerEntry) => {
if (!agentId || !requestDirectoryListing || !requestFilePreview) {
if (!agentId || !requestDirectoryListing) {
return;
}
@@ -353,9 +469,9 @@ function FileExplorerContent({
}
setSelectedEntryPath(entry.path);
requestFilePreview(agentId, entry.path);
enqueueFilePreview(entry.path, { priority: true });
},
[agentId, requestDirectoryListing, requestFilePreview]
[agentId, requestDirectoryListing, enqueueFilePreview]
);
const handleCopyPath = useCallback(async (path: string) => {
@@ -478,6 +594,28 @@ function FileExplorerContent({
router.back();
}, [agentId, serverId]);
const handleClosePreviewSheet = useCallback(() => {
setSelectedEntryPath(null);
}, []);
const handlePreviewSheetChange = useCallback((index: number) => {
if (index === -1) {
setSelectedEntryPath(null);
}
}, []);
const renderPreviewBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const handleRetryDirectory = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
@@ -485,6 +623,41 @@ function FileExplorerContent({
requestDirectoryListing(agentId, activePath);
}, [agentId, requestDirectoryListing, activePath]);
const handleRefresh = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
refreshPathRef.current = activePath;
refreshStartedRef.current = false;
setIsRefreshing(true);
requestDirectoryListing(agentId, activePath, { recordHistory: false });
}, [agentId, requestDirectoryListing, activePath]);
useEffect(() => {
if (!isRefreshing) {
return;
}
const refreshPath = refreshPathRef.current;
if (!refreshPath) {
return;
}
const isMatchingList =
pendingRequest?.mode === "list" && pendingRequest?.path === refreshPath;
if (isMatchingList) {
refreshStartedRef.current = true;
return;
}
if (refreshStartedRef.current) {
setIsRefreshing(false);
refreshPathRef.current = null;
refreshStartedRef.current = false;
}
}, [isRefreshing, pendingRequest?.mode, pendingRequest?.path]);
const handleBackNavigation = useCallback(() => {
if (!agentId) {
router.back();
@@ -609,26 +782,48 @@ function FileExplorerContent({
);
}, [activePath, showListLoadingBanner, viewMode]);
// Watch for completed file previews and process queue
useEffect(() => {
if (!explorerState) {
return;
}
setThumbnailLoadingMap((prev) => {
let changed = false;
const next = { ...prev };
Object.keys(prev).forEach((path) => {
if (explorerState.files.has(path)) {
delete next[path];
pendingThumbnailPathsRef.current.delete(path);
changed = true;
}
});
return changed ? next : prev;
});
}, [explorerState?.files.size]);
// Check which in-flight requests have completed
const completedPaths: string[] = [];
for (const path of inFlightPathsRef.current) {
if (explorerState.files.has(path)) {
completedPaths.push(path);
}
}
if (completedPaths.length === 0) {
return;
}
// Remove completed paths from in-flight set
for (const path of completedPaths) {
inFlightPathsRef.current.delete(path);
}
// Clear loading state for completed files
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
for (const path of completedPaths) {
delete next[path];
}
return next;
});
// Schedule next batch processing after state update
queueMicrotask(() => {
processNextThumbnail();
});
}, [explorerState?.files.size, processNextThumbnail]);
// Clear queue and loading state on path/view change
useEffect(() => {
pendingThumbnailPathsRef.current.clear();
thumbnailQueueRef.current = [];
inFlightPathsRef.current.clear();
setThumbnailLoadingMap({});
}, [activePath, viewMode]);
@@ -654,7 +849,7 @@ function FileExplorerContent({
return (
<View style={styles.container}>
<BackHeader
title={selectedEntryPath ?? (activePath || ".")}
title={activePath || "."}
onBack={handleBackNavigation}
rightContent={
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
@@ -664,99 +859,56 @@ function FileExplorerContent({
/>
<View style={styles.content}>
{shouldShowPreview ? (
<View style={styles.previewWrapper}>
<View style={styles.previewSection}>
{isPreviewLoading && !preview ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
</View>
) : !preview ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available yet</Text>
</View>
) : preview.kind === "text" ? (
<ScrollView
style={styles.textPreview}
horizontal={false}
contentContainerStyle={styles.textPreviewContent}
>
<ScrollView horizontal>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</ScrollView>
) : preview.kind === "image" && preview.content ? (
<View style={styles.imagePreviewContainer}>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${
preview.content
}`,
}}
style={styles.image}
resizeMode="contain"
/>
</View>
) : (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.entryMeta}>
{formatFileSize({ size: preview.size })}
</Text>
</View>
)}
<View style={styles.listSection}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRetryDirectory}>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
</View>
) : (
<View style={styles.listSection}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRetryDirectory}>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
) : showInitialListLoading ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading directory...</Text>
</View>
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<FlatList
ref={listScrollRef}
data={entries}
renderItem={renderEntry}
keyExtractor={(item) => item.path}
contentContainerStyle={
viewMode === "grid" ? styles.gridContent : styles.entriesContent
}
columnWrapperStyle={
viewMode === "grid" && listColumns > 1
? styles.gridColumnWrapper
: undefined
}
numColumns={listColumns}
key={listKey}
onScroll={handleListScroll}
scrollEventThrottle={16}
onLayout={restoreQueuedScrollOffset}
onContentSizeChange={restoreQueuedScrollOffset}
ListHeaderComponent={listHeaderComponent}
extraData={{ viewMode, thumbnailLoadingMap }}
initialNumToRender={20}
maxToRenderPerBatch={30}
windowSize={10}
onViewableItemsChanged={handleViewableItemsChangedRef.current}
viewabilityConfig={viewabilityConfigRef.current}
/>
)}
</View>
)}
) : showInitialListLoading ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading directory...</Text>
</View>
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<FlatList
ref={listScrollRef}
data={entries}
renderItem={renderEntry}
keyExtractor={(item) => item.path}
contentContainerStyle={
viewMode === "grid" ? styles.gridContent : styles.entriesContent
}
columnWrapperStyle={
viewMode === "grid" && listColumns > 1
? styles.gridColumnWrapper
: undefined
}
numColumns={listColumns}
key={listKey}
onScroll={handleListScroll}
scrollEventThrottle={16}
onLayout={restoreQueuedScrollOffset}
onContentSizeChange={restoreQueuedScrollOffset}
ListHeaderComponent={listHeaderComponent}
extraData={{ viewMode, thumbnailLoadingMap }}
initialNumToRender={20}
maxToRenderPerBatch={30}
windowSize={15}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} />
}
onViewableItemsChanged={handleViewableItemsChangedRef.current}
viewabilityConfig={viewabilityConfigRef.current}
/>
)}
</View>
</View>
<Modal
@@ -804,6 +956,61 @@ function FileExplorerContent({
) : null}
</View>
</Modal>
<BottomSheetModal
ref={previewSheetRef}
snapPoints={previewSnapPoints}
onChange={handlePreviewSheetChange}
backdropComponent={renderPreviewBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{selectedEntryPath?.split("/").pop() ?? "Preview"}
</Text>
<Pressable onPress={handleClosePreviewSheet} style={styles.sheetCloseButton}>
<X size={20} color={theme.colors.mutedForeground} />
</Pressable>
</View>
{isPreviewLoading && !preview ? (
<View style={styles.sheetCenterState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
</View>
) : !preview ? (
<View style={styles.sheetCenterState}>
<Text style={styles.emptyText}>No preview available yet</Text>
</View>
) : preview.kind === "text" ? (
<BottomSheetScrollView
style={styles.sheetContent}
contentContainerStyle={styles.sheetScrollContent}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</BottomSheetScrollView>
) : preview.kind === "image" && preview.content ? (
<BottomSheetView style={styles.sheetImageContainer}>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.sheetImage}
resizeMode="contain"
/>
</BottomSheetView>
) : (
<View style={styles.sheetCenterState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.entryMeta}>
{formatFileSize({ size: preview.size })}
</Text>
</View>
)}
</BottomSheetModal>
</View>
);
}
@@ -1440,4 +1647,52 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
height: "100%",
},
// Bottom sheet styles
sheetBackground: {
backgroundColor: theme.colors.card,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
sheetTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
flex: 1,
},
sheetCloseButton: {
padding: theme.spacing[2],
},
sheetContent: {
flex: 1,
},
sheetScrollContent: {
padding: theme.spacing[4],
},
sheetCenterState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
padding: theme.spacing[4],
},
sheetImageContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
sheetImage: {
width: "100%",
height: "100%",
},
}));

View File

@@ -189,22 +189,30 @@ export function MessageInput({
enableDuration: true,
});
// Cmd+D to toggle dictation on web
// Cmd+D to start/submit dictation, Escape to cancel
useEffect(() => {
if (!IS_WEB) return;
function handleKeyDown(event: KeyboardEvent) {
// Cmd+D: start dictation or submit if already dictating
if ((event.metaKey || event.ctrlKey) && event.key === "d") {
event.preventDefault();
if (isDictating) {
cancelDictation();
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
}
return;
}
// Escape: cancel dictation
if (event.key === "Escape" && isDictating) {
event.preventDefault();
cancelDictation();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isDictating, cancelDictation, startDictation]);
}, [isDictating, cancelDictation, confirmDictation, startDictation]);
// Animate overlay
useEffect(() => {
@@ -355,17 +363,25 @@ export function MessageInput({
if (!shouldHandleDesktopSubmit) return;
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
// Cmd+D or Ctrl+D: toggle dictation
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
if ((metaKey || ctrlKey) && event.nativeEvent.key === "d") {
event.preventDefault();
if (isDictating) {
cancelDictation();
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
}
return;
}
// Escape: cancel dictation
if (event.nativeEvent.key === "Escape" && isDictating) {
event.preventDefault();
cancelDictation();
return;
}
if (event.nativeEvent.key !== "Enter") return;
// Shift+Enter: add newline (default behavior, don't intercept)
@@ -561,6 +577,13 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(IS_WEB
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease-in-out",
}
: {}),
},
imagePreviewContainer: {
flexDirection: "row",

View File

@@ -45,7 +45,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
},
bubble: {
backgroundColor: theme.colors.primary,
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4],
@@ -53,7 +53,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
maxWidth: "80%",
},
text: {
color: theme.colors.primaryForeground,
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
lineHeight: 24,
},