From 95a7e87cea87d2172a8ba45d1ea6521ecff535c8 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 18 Jul 2026 03:05:19 -0600 Subject: [PATCH] Extract terminal file-upload logic into terminal-uploads.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit use-terminal.ts is a 916-line complexity hotspot. The self-contained drag/drop + paste upload orchestration (validation, upload loop, toast feedback) had no dependency on the terminal connection lifecycle, so it moves cleanly into a new terminal-uploads.ts module — continuing the established extraction pattern (terminal-socket.ts #659, terminal-surface.ts #664). Pure code move: the hook's uploadFiles callback now delegates to uploadTerminalFiles(). No behavior change. Hook shrinks 916 -> 867 lines and sheds the sonner/media-upload imports. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/hooks/terminal-uploads.ts | 63 ++++++++++++++++++++++++++ apps/web/src/hooks/use-terminal.ts | 53 +--------------------- 2 files changed, 65 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/hooks/terminal-uploads.ts diff --git a/apps/web/src/hooks/terminal-uploads.ts b/apps/web/src/hooks/terminal-uploads.ts new file mode 100644 index 00000000..db736fd8 --- /dev/null +++ b/apps/web/src/hooks/terminal-uploads.ts @@ -0,0 +1,63 @@ +import { toast } from "sonner"; + +import { isAcceptedUploadFile, uploadAgentMedia } from "@/lib/media-upload"; + +// Unified upload for both drag-and-drop and clipboard paste. The server +// handles delivery (clipboard injection or typed path) via the `inject` +// flag — the client just uploads and lets the server decide. +// +// Extracted from useTerminal so the hook body stays focused on the terminal +// connection lifecycle. Takes the currently-connected agent id (or null) plus +// a setter for the "uploading" UI flag, and orchestrates validation, upload, +// and toast feedback. +export function uploadTerminalFiles( + agentId: string | null, + files: File[], + setUploadingFiles: (uploading: boolean) => void +): void { + if (files.length === 0) return; + if (!agentId) { + toast.error("Connect to an agent before uploading files."); + return; + } + const accepted = files.filter((file) => isAcceptedUploadFile(file.name)); + const skipped = files.filter((file) => !isAcceptedUploadFile(file.name)); + if (accepted.length === 0) { + toast.error( + files.length === 1 + ? `Unsupported file type: ${files[0].name}` + : "None of the dropped files are a supported type." + ); + return; + } + if (skipped.length > 0) { + toast.info( + `Skipped ${skipped.length} unsupported file${ + skipped.length > 1 ? "s" : "" + }: ${skipped.map((file) => file.name).join(", ")}` + ); + } + setUploadingFiles(true); + void (async () => { + const failures: string[] = []; + try { + for (const file of accepted) { + try { + await uploadAgentMedia(agentId, file, { inject: true }); + } catch (err) { + console.warn(`Upload failed for ${file.name}:`, err); + failures.push(file.name); + } + } + } finally { + setUploadingFiles(false); + } + if (failures.length > 0) { + toast.error( + failures.length === 1 + ? `Failed to upload ${failures[0]}.` + : `Failed to upload ${failures.length} files.` + ); + } + })(); +} diff --git a/apps/web/src/hooks/use-terminal.ts b/apps/web/src/hooks/use-terminal.ts index 758e11d1..89d2a9fa 100644 --- a/apps/web/src/hooks/use-terminal.ts +++ b/apps/web/src/hooks/use-terminal.ts @@ -18,9 +18,7 @@ import { type TerminalUiState, } from "@/components/app/types"; import { api } from "@/lib/api"; -import { toast } from "sonner"; -import { isAcceptedUploadFile, uploadAgentMedia } from "@/lib/media-upload"; import { recordWSReconnect } from "@/lib/energy-metrics"; import { type ThemeId, getTerminalPalette } from "@/hooks/use-theme"; import { @@ -36,6 +34,7 @@ import { probeTerminalSocket, } from "@/hooks/terminal-socket"; import { createTerminalSurface } from "@/hooks/terminal-surface"; +import { uploadTerminalFiles } from "@/hooks/terminal-uploads"; function focusTerminalSurface(term: XTerm | null): void { if (!term) return; @@ -589,56 +588,8 @@ export function useTerminal(args: { terminalRef.current?.focus(); }, []); - // Unified upload for both drag-and-drop and clipboard paste. The server - // handles delivery (clipboard injection or typed path) via the `inject` - // flag — the client just uploads and lets the server decide. const uploadFiles = useCallback((files: File[]) => { - if (files.length === 0) return; - const agentId = connectedAgentIdRef.current; - if (!agentId) { - toast.error("Connect to an agent before uploading files."); - return; - } - const accepted = files.filter((file) => isAcceptedUploadFile(file.name)); - const skipped = files.filter((file) => !isAcceptedUploadFile(file.name)); - if (accepted.length === 0) { - toast.error( - files.length === 1 - ? `Unsupported file type: ${files[0].name}` - : "None of the dropped files are a supported type." - ); - return; - } - if (skipped.length > 0) { - toast.info( - `Skipped ${skipped.length} unsupported file${ - skipped.length > 1 ? "s" : "" - }: ${skipped.map((file) => file.name).join(", ")}` - ); - } - setUploadingFiles(true); - void (async () => { - const failures: string[] = []; - try { - for (const file of accepted) { - try { - await uploadAgentMedia(agentId, file, { inject: true }); - } catch (err) { - console.warn(`Upload failed for ${file.name}:`, err); - failures.push(file.name); - } - } - } finally { - setUploadingFiles(false); - } - if (failures.length > 0) { - toast.error( - failures.length === 1 - ? `Failed to upload ${failures[0]}.` - : `Failed to upload ${failures.length} files.` - ); - } - })(); + uploadTerminalFiles(connectedAgentIdRef.current, files, setUploadingFiles); }, []); uploadFilesRef.current = uploadFiles;