From 9d7e44851c1e5e0d24b04a3e9b256bbe506c037c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 5 Jan 2026 08:41:43 +0700 Subject: [PATCH] feat(file-explorer): add parallel thumbnail loading and file preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PRODUCTION.md | 313 ++++++++++ packages/app/src/app/file-explorer.tsx | 557 +++++++++++++----- packages/app/src/components/message-input.tsx | 33 +- packages/app/src/components/message.tsx | 4 +- 4 files changed, 749 insertions(+), 158 deletions(-) create mode 100644 PRODUCTION.md diff --git a/PRODUCTION.md b/PRODUCTION.md new file mode 100644 index 000000000..847df2e64 --- /dev/null +++ b/PRODUCTION.md @@ -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 # 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://@?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 + + + + + Label + com.paseo.daemon + ProgramArguments + + /usr/local/bin/paseo + start + + RunAtLoad + + KeepAlive + + + +``` + +**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 = 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/ + + 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 diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index a897825de..9863638af 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -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>(new Set()); const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState>({}); - 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(null); + const previewSnapPoints = useMemo(() => ["80%"], []); + + // Thumbnail queue state - allows up to MAX_CONCURRENT_THUMBNAILS in parallel + const MAX_CONCURRENT_THUMBNAILS = 2; + const thumbnailQueueRef = useRef([]); + const inFlightPathsRef = useRef>(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(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(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 }) => { - 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) => ( + + ), + [] + ); + 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 ( @@ -664,99 +859,56 @@ function FileExplorerContent({ /> - {shouldShowPreview ? ( - - - {isPreviewLoading && !preview ? ( - - - Loading file... - - ) : !preview ? ( - - No preview available yet - - ) : preview.kind === "text" ? ( - - - {preview.content} - - - ) : preview.kind === "image" && preview.content ? ( - - - - ) : ( - - Binary preview unavailable - - {formatFileSize({ size: preview.size })} - - - )} + + {error ? ( + + {error} + + Retry + - - ) : ( - - {error ? ( - - {error} - - Retry - - - ) : showInitialListLoading ? ( - - - Loading directory... - - ) : entries.length === 0 ? ( - - Directory is empty - - ) : ( - 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} - /> - )} - - )} + ) : showInitialListLoading ? ( + + + Loading directory... + + ) : entries.length === 0 ? ( + + Directory is empty + + ) : ( + 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={ + + } + onViewableItemsChanged={handleViewableItemsChangedRef.current} + viewabilityConfig={viewabilityConfigRef.current} + /> + )} + + + + + + {selectedEntryPath?.split("/").pop() ?? "Preview"} + + + + + + {isPreviewLoading && !preview ? ( + + + Loading file... + + ) : !preview ? ( + + No preview available yet + + ) : preview.kind === "text" ? ( + + + {preview.content} + + + ) : preview.kind === "image" && preview.content ? ( + + + + ) : ( + + Binary preview unavailable + + {formatFileSize({ size: preview.size })} + + + )} + ); } @@ -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%", + }, })); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 4d9ec5aee..e2ecff4a6 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 3fd5a303d..3ce0ed365 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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, },