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:
co-authored by
Claude Opus 4.5
parent
edb3258ae9
commit
9d7e44851c
+313
@@ -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
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
|
RefreshControl,
|
||||||
ViewToken,
|
ViewToken,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
@@ -23,6 +24,12 @@ import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
|
import {
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import {
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -173,43 +180,43 @@ function FileExplorerContent({
|
|||||||
const showInitialListLoading = isListingLoading && entries.length === 0;
|
const showInitialListLoading = isListingLoading && entries.length === 0;
|
||||||
const showListLoadingBanner = isListingLoading && entries.length > 0;
|
const showListLoadingBanner = isListingLoading && entries.length > 0;
|
||||||
const isPreviewLoading = Boolean(
|
const isPreviewLoading = Boolean(
|
||||||
isExplorerLoading && pendingRequest?.mode === "file"
|
isExplorerLoading &&
|
||||||
|
pendingRequest?.mode === "file" &&
|
||||||
|
pendingRequest?.path === selectedEntryPath
|
||||||
);
|
);
|
||||||
const error = explorerState?.lastError ?? null;
|
const error = explorerState?.lastError ?? null;
|
||||||
const preview = selectedEntryPath
|
const preview = selectedEntryPath
|
||||||
? explorerState?.files.get(selectedEntryPath)
|
? explorerState?.files.get(selectedEntryPath)
|
||||||
: null;
|
: null;
|
||||||
const shouldShowPreview = Boolean(selectedEntryPath);
|
const shouldShowPreview = Boolean(selectedEntryPath);
|
||||||
const pendingThumbnailPathsRef = useRef<Set<string>>(new Set());
|
|
||||||
const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState<Record<string, boolean>>({});
|
const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState<Record<string, boolean>>({});
|
||||||
const viewabilityConfigRef = useRef({ itemVisiblePercentThreshold: 50 });
|
const viewabilityConfigRef = useRef({
|
||||||
const gridColumnCount = useMemo(() => {
|
itemVisiblePercentThreshold: 10,
|
||||||
if (windowWidth >= 1500) {
|
minimumViewTime: 0,
|
||||||
return 6;
|
});
|
||||||
}
|
|
||||||
if (windowWidth >= 1200) {
|
// Bottom sheet for file preview
|
||||||
return 5;
|
const previewSheetRef = useRef<BottomSheetModal>(null);
|
||||||
}
|
const previewSnapPoints = useMemo(() => ["80%"], []);
|
||||||
if (windowWidth >= 960) {
|
|
||||||
return 4;
|
// Thumbnail queue state - allows up to MAX_CONCURRENT_THUMBNAILS in parallel
|
||||||
}
|
const MAX_CONCURRENT_THUMBNAILS = 2;
|
||||||
if (windowWidth >= 720) {
|
const thumbnailQueueRef = useRef<string[]>([]);
|
||||||
return 3;
|
const inFlightPathsRef = useRef<Set<string>>(new Set());
|
||||||
}
|
const THUMBNAIL_TIMEOUT_MS = 15000;
|
||||||
if (windowWidth >= 520) {
|
const gridColumnCount = 2;
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}, [windowWidth]);
|
|
||||||
const listColumns = viewMode === "grid" ? gridColumnCount : 1;
|
const listColumns = viewMode === "grid" ? gridColumnCount : 1;
|
||||||
const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list";
|
const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list";
|
||||||
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
|
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
|
||||||
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
||||||
const [menuHeight, setMenuHeight] = useState(0);
|
const [menuHeight, setMenuHeight] = useState(0);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const agentIdRef = useRef(agentId);
|
const agentIdRef = useRef(agentId);
|
||||||
const viewModeRef = useRef(viewMode);
|
const viewModeRef = useRef(viewMode);
|
||||||
const requestFilePreviewRef = useRef(requestFilePreview);
|
const requestFilePreviewRef = useRef(requestFilePreview);
|
||||||
const explorerFilesRef = useRef(explorerState?.files);
|
const explorerFilesRef = useRef(explorerState?.files);
|
||||||
|
const refreshPathRef = useRef<string | null>(null);
|
||||||
|
const refreshStartedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
agentIdRef.current = agentId;
|
agentIdRef.current = agentId;
|
||||||
@@ -227,37 +234,137 @@ function FileExplorerContent({
|
|||||||
explorerFilesRef.current = explorerState?.files;
|
explorerFilesRef.current = explorerState?.files;
|
||||||
}, [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(
|
const handleViewableItemsChangedRef = useRef(
|
||||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||||
const currentAgentId = agentIdRef.current;
|
|
||||||
const currentViewMode = viewModeRef.current;
|
const currentViewMode = viewModeRef.current;
|
||||||
const currentRequestFilePreview = requestFilePreviewRef.current;
|
|
||||||
if (!currentAgentId || currentViewMode !== "grid" || !currentRequestFilePreview) {
|
if (currentViewMode !== "grid") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
viewableItems.forEach((token) => {
|
viewableItems.forEach((token) => {
|
||||||
const item = token.item as ExplorerEntry | undefined;
|
const item = token.item as ExplorerEntry | undefined;
|
||||||
if (!item) {
|
if (!item || getEntryDisplayKind(item) !== "image") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
enqueueFilePreviewRef.current?.(item.path);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const enqueueFilePreviewRef = useRef(enqueueFilePreview);
|
||||||
|
useEffect(() => {
|
||||||
|
enqueueFilePreviewRef.current = enqueueFilePreview;
|
||||||
|
}, [enqueueFilePreview]);
|
||||||
|
|
||||||
const restoreQueuedScrollOffset = useCallback(() => {
|
const restoreQueuedScrollOffset = useCallback(() => {
|
||||||
if (pendingScrollRestoreRef.current === null) {
|
if (pendingScrollRestoreRef.current === null) {
|
||||||
return;
|
return;
|
||||||
@@ -282,6 +389,15 @@ function FileExplorerContent({
|
|||||||
setSelectedEntryPath(null);
|
setSelectedEntryPath(null);
|
||||||
}, [activePath]);
|
}, [activePath]);
|
||||||
|
|
||||||
|
// Open/close preview sheet based on selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEntryPath) {
|
||||||
|
previewSheetRef.current?.present();
|
||||||
|
} else {
|
||||||
|
previewSheetRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [selectedEntryPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldShowPreview) {
|
if (shouldShowPreview) {
|
||||||
return;
|
return;
|
||||||
@@ -312,14 +428,14 @@ function FileExplorerContent({
|
|||||||
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
|
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId || !normalizedFileParam || !requestFilePreview) {
|
if (!agentId || !normalizedFileParam) {
|
||||||
pendingFileParamRef.current = null;
|
pendingFileParamRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingFileParamRef.current = normalizedFileParam;
|
pendingFileParamRef.current = normalizedFileParam;
|
||||||
requestFilePreview(agentId, normalizedFileParam);
|
enqueueFilePreview(normalizedFileParam, { priority: true });
|
||||||
}, [agentId, normalizedFileParam, requestFilePreview]);
|
}, [agentId, normalizedFileParam, enqueueFilePreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
@@ -342,7 +458,7 @@ function FileExplorerContent({
|
|||||||
|
|
||||||
const handleEntryPress = useCallback(
|
const handleEntryPress = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!agentId || !requestDirectoryListing || !requestFilePreview) {
|
if (!agentId || !requestDirectoryListing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,9 +469,9 @@ function FileExplorerContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedEntryPath(entry.path);
|
setSelectedEntryPath(entry.path);
|
||||||
requestFilePreview(agentId, entry.path);
|
enqueueFilePreview(entry.path, { priority: true });
|
||||||
},
|
},
|
||||||
[agentId, requestDirectoryListing, requestFilePreview]
|
[agentId, requestDirectoryListing, enqueueFilePreview]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyPath = useCallback(async (path: string) => {
|
const handleCopyPath = useCallback(async (path: string) => {
|
||||||
@@ -478,6 +594,28 @@ function FileExplorerContent({
|
|||||||
router.back();
|
router.back();
|
||||||
}, [agentId, serverId]);
|
}, [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(() => {
|
const handleRetryDirectory = useCallback(() => {
|
||||||
if (!agentId || !requestDirectoryListing) {
|
if (!agentId || !requestDirectoryListing) {
|
||||||
return;
|
return;
|
||||||
@@ -485,6 +623,41 @@ function FileExplorerContent({
|
|||||||
requestDirectoryListing(agentId, activePath);
|
requestDirectoryListing(agentId, activePath);
|
||||||
}, [agentId, requestDirectoryListing, 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(() => {
|
const handleBackNavigation = useCallback(() => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
router.back();
|
router.back();
|
||||||
@@ -609,26 +782,48 @@ function FileExplorerContent({
|
|||||||
);
|
);
|
||||||
}, [activePath, showListLoadingBanner, viewMode]);
|
}, [activePath, showListLoadingBanner, viewMode]);
|
||||||
|
|
||||||
|
// Watch for completed file previews and process queue
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!explorerState) {
|
if (!explorerState) {
|
||||||
return;
|
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(() => {
|
useEffect(() => {
|
||||||
pendingThumbnailPathsRef.current.clear();
|
thumbnailQueueRef.current = [];
|
||||||
|
inFlightPathsRef.current.clear();
|
||||||
setThumbnailLoadingMap({});
|
setThumbnailLoadingMap({});
|
||||||
}, [activePath, viewMode]);
|
}, [activePath, viewMode]);
|
||||||
|
|
||||||
@@ -654,7 +849,7 @@ function FileExplorerContent({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader
|
<BackHeader
|
||||||
title={selectedEntryPath ?? (activePath || ".")}
|
title={activePath || "."}
|
||||||
onBack={handleBackNavigation}
|
onBack={handleBackNavigation}
|
||||||
rightContent={
|
rightContent={
|
||||||
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
|
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
|
||||||
@@ -664,99 +859,56 @@ function FileExplorerContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{shouldShowPreview ? (
|
<View style={styles.listSection}>
|
||||||
<View style={styles.previewWrapper}>
|
{error ? (
|
||||||
<View style={styles.previewSection}>
|
<View style={styles.centerState}>
|
||||||
{isPreviewLoading && !preview ? (
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
<View style={styles.centerState}>
|
<Pressable style={styles.retryButton} onPress={handleRetryDirectory}>
|
||||||
<ActivityIndicator size="small" />
|
<Text style={styles.retryButtonText}>Retry</Text>
|
||||||
<Text style={styles.loadingText}>Loading file...</Text>
|
</Pressable>
|
||||||
</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>
|
</View>
|
||||||
</View>
|
) : showInitialListLoading ? (
|
||||||
) : (
|
<View style={styles.centerState}>
|
||||||
<View style={styles.listSection}>
|
<ActivityIndicator size="small" />
|
||||||
{error ? (
|
<Text style={styles.loadingText}>Loading directory...</Text>
|
||||||
<View style={styles.centerState}>
|
</View>
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
) : entries.length === 0 ? (
|
||||||
<Pressable style={styles.retryButton} onPress={handleRetryDirectory}>
|
<View style={styles.centerState}>
|
||||||
<Text style={styles.retryButtonText}>Retry</Text>
|
<Text style={styles.emptyText}>Directory is empty</Text>
|
||||||
</Pressable>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
) : showInitialListLoading ? (
|
<FlatList
|
||||||
<View style={styles.centerState}>
|
ref={listScrollRef}
|
||||||
<ActivityIndicator size="small" />
|
data={entries}
|
||||||
<Text style={styles.loadingText}>Loading directory...</Text>
|
renderItem={renderEntry}
|
||||||
</View>
|
keyExtractor={(item) => item.path}
|
||||||
) : entries.length === 0 ? (
|
contentContainerStyle={
|
||||||
<View style={styles.centerState}>
|
viewMode === "grid" ? styles.gridContent : styles.entriesContent
|
||||||
<Text style={styles.emptyText}>Directory is empty</Text>
|
}
|
||||||
</View>
|
columnWrapperStyle={
|
||||||
) : (
|
viewMode === "grid" && listColumns > 1
|
||||||
<FlatList
|
? styles.gridColumnWrapper
|
||||||
ref={listScrollRef}
|
: undefined
|
||||||
data={entries}
|
}
|
||||||
renderItem={renderEntry}
|
numColumns={listColumns}
|
||||||
keyExtractor={(item) => item.path}
|
key={listKey}
|
||||||
contentContainerStyle={
|
onScroll={handleListScroll}
|
||||||
viewMode === "grid" ? styles.gridContent : styles.entriesContent
|
scrollEventThrottle={16}
|
||||||
}
|
onLayout={restoreQueuedScrollOffset}
|
||||||
columnWrapperStyle={
|
onContentSizeChange={restoreQueuedScrollOffset}
|
||||||
viewMode === "grid" && listColumns > 1
|
ListHeaderComponent={listHeaderComponent}
|
||||||
? styles.gridColumnWrapper
|
extraData={{ viewMode, thumbnailLoadingMap }}
|
||||||
: undefined
|
initialNumToRender={20}
|
||||||
}
|
maxToRenderPerBatch={30}
|
||||||
numColumns={listColumns}
|
windowSize={15}
|
||||||
key={listKey}
|
refreshControl={
|
||||||
onScroll={handleListScroll}
|
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} />
|
||||||
scrollEventThrottle={16}
|
}
|
||||||
onLayout={restoreQueuedScrollOffset}
|
onViewableItemsChanged={handleViewableItemsChangedRef.current}
|
||||||
onContentSizeChange={restoreQueuedScrollOffset}
|
viewabilityConfig={viewabilityConfigRef.current}
|
||||||
ListHeaderComponent={listHeaderComponent}
|
/>
|
||||||
extraData={{ viewMode, thumbnailLoadingMap }}
|
)}
|
||||||
initialNumToRender={20}
|
</View>
|
||||||
maxToRenderPerBatch={30}
|
|
||||||
windowSize={10}
|
|
||||||
onViewableItemsChanged={handleViewableItemsChangedRef.current}
|
|
||||||
viewabilityConfig={viewabilityConfigRef.current}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -804,6 +956,61 @@ function FileExplorerContent({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1440,4 +1647,52 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "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%",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -189,22 +189,30 @@ export function MessageInput({
|
|||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cmd+D to toggle dictation on web
|
// Cmd+D to start/submit dictation, Escape to cancel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) return;
|
if (!IS_WEB) return;
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
// Cmd+D: start dictation or submit if already dictating
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "d") {
|
if ((event.metaKey || event.ctrlKey) && event.key === "d") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isDictating) {
|
if (isDictating) {
|
||||||
cancelDictation();
|
sendAfterTranscriptRef.current = true;
|
||||||
|
confirmDictation();
|
||||||
} else {
|
} else {
|
||||||
startDictation();
|
startDictation();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Escape: cancel dictation
|
||||||
|
if (event.key === "Escape" && isDictating) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelDictation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isDictating, cancelDictation, startDictation]);
|
}, [isDictating, cancelDictation, confirmDictation, startDictation]);
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -355,17 +363,25 @@ export function MessageInput({
|
|||||||
if (!shouldHandleDesktopSubmit) return;
|
if (!shouldHandleDesktopSubmit) return;
|
||||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
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") {
|
if ((metaKey || ctrlKey) && event.nativeEvent.key === "d") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isDictating) {
|
if (isDictating) {
|
||||||
cancelDictation();
|
sendAfterTranscriptRef.current = true;
|
||||||
|
confirmDictation();
|
||||||
} else {
|
} else {
|
||||||
startDictation();
|
startDictation();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape: cancel dictation
|
||||||
|
if (event.nativeEvent.key === "Escape" && isDictating) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelDictation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.nativeEvent.key !== "Enter") return;
|
if (event.nativeEvent.key !== "Enter") return;
|
||||||
|
|
||||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||||
@@ -561,6 +577,13 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
xs: theme.spacing[3],
|
xs: theme.spacing[3],
|
||||||
md: theme.spacing[4],
|
md: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
...(IS_WEB
|
||||||
|
? {
|
||||||
|
transitionProperty: "border-color",
|
||||||
|
transitionDuration: "200ms",
|
||||||
|
transitionTimingFunction: "ease-in-out",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
imagePreviewContainer: {
|
imagePreviewContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
},
|
},
|
||||||
bubble: {
|
bubble: {
|
||||||
backgroundColor: theme.colors.primary,
|
backgroundColor: theme.colors.muted,
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderTopRightRadius: theme.borderRadius.sm,
|
borderTopRightRadius: theme.borderRadius.sm,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
@@ -53,7 +53,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
maxWidth: "80%",
|
maxWidth: "80%",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
color: theme.colors.primaryForeground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
lineHeight: 24,
|
lineHeight: 24,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user