diff --git a/apps/web/src/components/app/media-content.tsx b/apps/web/src/components/app/media-content.tsx new file mode 100644 index 00000000..efdc35f7 --- /dev/null +++ b/apps/web/src/components/app/media-content.tsx @@ -0,0 +1,348 @@ +import { type RefObject, useCallback, useRef, useState } from "react"; +import { + Check, + ExternalLink, + FileText, + Image, + MonitorPlay, + Upload, + User, + File as FileIcon, + Video, +} from "lucide-react"; + +import { type MediaFile } from "@/components/app/types"; +import { MediaActions } from "@/components/app/media-lightbox"; +import { + fileExtension, + isTextFile, + stripTimestamp, +} from "@/components/app/media-file-utils"; +import { ActivityBars } from "@/components/ui/activity-bars"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const ACCEPTED_EXTENSIONS = + ".png,.jpg,.jpeg,.gif,.webp,.mp4,.pdf,.txt,.md,.json,.yaml,.yml,.toml,.csv,.log,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.go,.rs,.sh,.sql,.diff,.patch,.env,.ini,.cfg,.conf,.swift,.kt,.java,.c,.cpp,.h,.hpp,.rb,.php,.lua,.zig,.nim,.r,.m,.ex,.exs,.erl,.hs"; + +export type MediaContentProps = { + mediaFiles: MediaFile[]; + selectedAgentId: string | null; + animatingMediaKeys: Set; + mediaViewportRef: RefObject; + openLightbox: (file: MediaFile) => void; + hasStream: boolean; + streamUrl: string | null; + onUploadFile?: (agentId: string, file: File) => Promise; +}; + +function LiveStreamSection({ + streamUrl, + selectedAgentId, +}: { + streamUrl: string; + selectedAgentId: string; +}): JSX.Element { + const popOut = useCallback(() => { + window.open( + `/api/v1/agents/${selectedAgentId}/stream/viewer`, + `stream-${selectedAgentId}`, + "width=1300,height=860,menubar=no,toolbar=no,location=no,status=no" + ); + }, [selectedAgentId]); + + return ( +
+
+ + + Live Stream + +
+ +
+
+
+
+ Live browser stream +
+
+
+ ); +} + +function MediaItemCard({ + file, + animating, + cacheBustUrl, + openLightbox, +}: { + file: MediaFile; + animating: boolean; + cacheBustUrl: string; + openLightbox: (file: MediaFile) => void; +}): JSX.Element { + const isStream = file.source === "stream"; + const isText = file.source === "text" || isTextFile(file.name); + const isDocument = /\.pdf$/i.test(file.name); + const isUser = file.source === "user"; + const unseen = !file.seen; + + return ( +
+ {isStream ? ( +
+ + + Stream recording + + + {new Date(file.updatedAt).toLocaleString()} + +
+ ) : ( +
+ {new Date(file.updatedAt).toLocaleString()} + {isUser ? ( + + + Shared by you + + ) : null} +
+ )} + {isText || isDocument ? ( + + ) : /\.mp4$/i.test(file.name) ? ( +
+
+ ) : ( + + )} +
+ {file.description ?
{file.description}
: null} +
+ {Math.max(1, Math.round(file.size / 1024))} KB + +
+
+
+ ); +} + +export function MediaContent({ + mediaFiles, + selectedAgentId, + animatingMediaKeys, + mediaViewportRef, + openLightbox, + hasStream, + streamUrl, + onUploadFile, +}: MediaContentProps): JSX.Element { + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadSuccess, setUploadSuccess] = useState(false); + const successTimerRef = useRef(null); + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !selectedAgentId || !onUploadFile) return; + setUploading(true); + setUploadError(null); + setUploadSuccess(false); + try { + await onUploadFile(selectedAgentId, file); + setUploadSuccess(true); + if (successTimerRef.current) + window.clearTimeout(successTimerRef.current); + successTimerRef.current = window.setTimeout(() => { + setUploadSuccess(false); + successTimerRef.current = null; + }, 4000); + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploading(false); + } + e.target.value = ""; + }, + [selectedAgentId, onUploadFile] + ); + + const triggerFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + return ( + <> + {hasStream && streamUrl && selectedAgentId ? ( + + ) : null} + + {selectedAgentId && onUploadFile ? ( +
+
+ + + {uploadError ? ( + + {uploadError} + + ) : null} +
+ {uploadSuccess ? ( +

+ Tell the agent about the file so it knows to look. +

+ ) : null} +
+ ) : null} + +
+ {mediaFiles.length === 0 && !hasStream ? ( +
+
+
+ +
+
+ {selectedAgentId ? ( + <> + No media yet.{" "} + {" "} + or wait for agents to share screenshots, videos and + documents. + + ) : ( + "Focus an agent to view media." + )} +
+
+
+ ) : mediaFiles.length === 0 ? null : ( + mediaFiles.map((file) => { + const mediaKey = `${file.name}:${file.updatedAt}`; + const cacheBustUrl = `${file.url}?t=${encodeURIComponent(file.updatedAt)}`; + return ( + + ); + }) + )} +
+ + ); +} diff --git a/apps/web/src/components/app/media-sidebar.tsx b/apps/web/src/components/app/media-sidebar.tsx index 4c84aaba..dc896437 100644 --- a/apps/web/src/components/app/media-sidebar.tsx +++ b/apps/web/src/components/app/media-sidebar.tsx @@ -1,29 +1,13 @@ -import { type RefObject, useCallback, useRef, useState } from "react"; -import { - Check, - ChevronRight, - ExternalLink, - FileText, - Image, - MonitorPlay, - Pin, - PinOff, - X, - File as FileIcon, - Video, - Upload, - User, -} from "lucide-react"; +import { type RefObject } from "react"; +import { ChevronRight, Pin, PinOff, X } from "lucide-react"; import { type AgentPin, type MediaFile } from "@/components/app/types"; import { type MediaSidebarTab } from "@/lib/store"; -import { MediaActions } from "@/components/app/media-lightbox"; -import { isTextFile, stripTimestamp } from "@/components/app/media-file-utils"; +import { MediaContent } from "@/components/app/media-content"; import { MessagesPanel } from "@/components/app/messages-panel"; import { PinsPanel } from "@/components/app/pins-panel"; import { ReviewsSidebarContent } from "@/components/app/reviews-sidebar"; import { useAgentReviews } from "@/hooks/use-agent-reviews"; -import { ActivityBars } from "@/components/ui/activity-bars"; import { Button } from "@/components/ui/button"; import { glassPanel } from "@/lib/glass"; import { cn } from "@/lib/utils"; @@ -38,14 +22,6 @@ export { MEDIA_SIDEBAR_WIDTH_PX, } from "@/components/app/media-sidebar-constants"; -const ACCEPTED_EXTENSIONS = - ".png,.jpg,.jpeg,.gif,.webp,.mp4,.pdf,.txt,.md,.json,.yaml,.yml,.toml,.csv,.log,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.go,.rs,.sh,.sql,.diff,.patch,.env,.ini,.cfg,.conf,.swift,.kt,.java,.c,.cpp,.h,.hpp,.rb,.php,.lua,.zig,.nim,.r,.m,.ex,.exs,.erl,.hs"; - -function fileExtension(name: string): string { - const dot = name.lastIndexOf("."); - return dot === -1 ? "" : name.slice(dot + 1).toLowerCase(); -} - type MediaSidebarSharedProps = { mediaFiles: MediaFile[]; selectedAgentId: string | null; @@ -83,311 +59,6 @@ type MediaSidebarContentProps = MediaSidebarSharedProps & { className?: string; }; -function LiveStreamSection({ - streamUrl, - selectedAgentId, -}: { - streamUrl: string; - selectedAgentId: string; -}): JSX.Element { - const popOut = useCallback(() => { - window.open( - `/api/v1/agents/${selectedAgentId}/stream/viewer`, - `stream-${selectedAgentId}`, - "width=1300,height=860,menubar=no,toolbar=no,location=no,status=no" - ); - }, [selectedAgentId]); - - return ( -
-
- - - Live Stream - -
- -
-
-
-
- Live browser stream -
-
-
- ); -} - -function MediaContent({ - mediaFiles, - selectedAgentId, - animatingMediaKeys, - mediaViewportRef, - openLightbox, - hasStream, - streamUrl, - onUploadFile, -}: Pick< - MediaSidebarSharedProps, - | "mediaFiles" - | "selectedAgentId" - | "animatingMediaKeys" - | "mediaViewportRef" - | "openLightbox" - | "hasStream" - | "streamUrl" - | "onUploadFile" ->): JSX.Element { - const fileInputRef = useRef(null); - const [uploading, setUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const [uploadSuccess, setUploadSuccess] = useState(false); - const successTimerRef = useRef(null); - - const handleFileChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file || !selectedAgentId || !onUploadFile) return; - setUploading(true); - setUploadError(null); - setUploadSuccess(false); - try { - await onUploadFile(selectedAgentId, file); - setUploadSuccess(true); - if (successTimerRef.current) - window.clearTimeout(successTimerRef.current); - successTimerRef.current = window.setTimeout(() => { - setUploadSuccess(false); - successTimerRef.current = null; - }, 4000); - } catch (err) { - setUploadError(err instanceof Error ? err.message : "Upload failed"); - } finally { - setUploading(false); - } - // Reset input so the same file can be re-uploaded - e.target.value = ""; - }, - [selectedAgentId, onUploadFile] - ); - - const triggerFilePicker = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - return ( - <> - {hasStream && streamUrl && selectedAgentId ? ( - - ) : null} - - {/* Upload button bar */} - {selectedAgentId && onUploadFile ? ( -
-
- - - {uploadError ? ( - - {uploadError} - - ) : null} -
- {uploadSuccess ? ( -

- Tell the agent about the file so it knows to look. -

- ) : null} -
- ) : null} - -
- {mediaFiles.length === 0 && !hasStream ? ( -
-
-
- -
-
- {selectedAgentId ? ( - <> - No media yet.{" "} - {" "} - or wait for agents to share screenshots, videos and - documents. - - ) : ( - "Focus an agent to view media." - )} -
-
-
- ) : mediaFiles.length === 0 ? null : ( - mediaFiles.map((file) => { - const mediaKey = `${file.name}:${file.updatedAt}`; - const cacheBustUrl = `${file.url}?t=${encodeURIComponent(file.updatedAt)}`; - const animating = animatingMediaKeys.has(mediaKey); - const unseen = !file.seen; - - const isStream = file.source === "stream"; - const isText = file.source === "text" || isTextFile(file.name); - const isDocument = /\.pdf$/i.test(file.name); - const isUser = file.source === "user"; - - return ( -
- {isStream ? ( -
- - - Stream recording - - - {new Date(file.updatedAt).toLocaleString()} - -
- ) : ( -
- {new Date(file.updatedAt).toLocaleString()} - {isUser ? ( - - - Shared by you - - ) : null} -
- )} - {isText || isDocument ? ( - - ) : /\.mp4$/i.test(file.name) ? ( -
-
- ) : ( - - )} -
- {file.description ?
{file.description}
: null} -
- {Math.max(1, Math.round(file.size / 1024))} KB - -
-
-
- ); - }) - )} -
- - ); -} - export function MediaSidebarContent({ mediaFiles, selectedAgentId,