From dc531cbcf5e569e7971a1dd83d0a4eb448155139 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 21:22:36 +0000 Subject: [PATCH 1/2] feat(web): port workspace page from platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the workspace browser (file tree + file viewer) from vellum-assistant-platform to the new Vite + React Router v7 SPA. New domain: domains/workspace/ - workspace-page.tsx — route component consuming assistantId from ChatLayout - workspace-browser.tsx — split-pane layout (tree sidebar + file viewer) - workspace-tree.tsx — recursive file tree with search, create file/folder - workspace-file-viewer.tsx — markdown/JSON/text/image/video/binary viewer with editing - utils/file-json.ts — isJson() and prettifyJson() helpers Route: /assistant/workspace (nested under ChatLayout, matches platform URL) Divergences from platform: - Removed 'use client' directives (Vite SPA, not Next.js) - Replaced useAppRootContainer portal with document.body (no AppRootContext in new repo) - Updated all imports to new repo conventions (@/, .js extensions, design library paths) - Kebab-case filenames per STYLE_GUIDE.md Closes LUM-1655 Co-Authored-By: ashlee@vellum.ai --- .../components/workspace-browser.tsx | 104 +++ .../components/workspace-file-viewer.tsx | 764 ++++++++++++++++++ .../components/workspace-tree.test.tsx | 122 +++ .../workspace/components/workspace-tree.tsx | 647 +++++++++++++++ .../src/domains/workspace/utils/file-json.ts | 48 ++ .../src/domains/workspace/workspace-page.tsx | 12 + apps/web/src/routes.tsx | 2 + 7 files changed, 1699 insertions(+) create mode 100644 apps/web/src/domains/workspace/components/workspace-browser.tsx create mode 100644 apps/web/src/domains/workspace/components/workspace-file-viewer.tsx create mode 100644 apps/web/src/domains/workspace/components/workspace-tree.test.tsx create mode 100644 apps/web/src/domains/workspace/components/workspace-tree.tsx create mode 100644 apps/web/src/domains/workspace/utils/file-json.ts create mode 100644 apps/web/src/domains/workspace/workspace-page.tsx diff --git a/apps/web/src/domains/workspace/components/workspace-browser.tsx b/apps/web/src/domains/workspace/components/workspace-browser.tsx new file mode 100644 index 00000000000..5f14e2464be --- /dev/null +++ b/apps/web/src/domains/workspace/components/workspace-browser.tsx @@ -0,0 +1,104 @@ +/** + * Top-level workspace browser layout. Renders a file tree sidebar (hidden on + * mobile behind a drawer) and a file viewer pane side-by-side. + */ + +import { useCallback, useState } from "react"; + +import { + MobileSidebarDrawer, + MobileSidebarTrigger, +} from "@/components/mobile-sidebar-drawer.js"; +import { WorkspaceFileViewer } from "@/domains/workspace/components/workspace-file-viewer.js"; +import { WorkspaceTree } from "@/domains/workspace/components/workspace-tree.js"; + +export type WorkspaceViewMode = "preview" | "source"; + +export function WorkspaceBrowser({ assistantId }: { assistantId: string }) { + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [selectedPath, setSelectedPath] = useState(null); + const [showHidden, setShowHidden] = useState(false); + const [viewMode, setViewMode] = useState("preview"); + + const handleToggleExpand = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const handleExpandPath = useCallback((path: string) => { + setExpandedPaths((prev) => { + if (prev.has(path)) return prev; + const next = new Set(prev); + next.add(path); + return next; + }); + }, []); + + const [drawerOpen, setDrawerOpen] = useState(false); + + const handleSelectPath = useCallback((path: string) => { + setSelectedPath(path); + setDrawerOpen(false); + }, []); + + const treeProps = { + assistantId, + expandedPaths, + selectedPath, + showHidden, + onToggleExpand: handleToggleExpand, + onExpandPath: handleExpandPath, + onSelectPath: handleSelectPath, + onToggleShowHidden: () => setShowHidden((v) => !v), + }; + + return ( +
+
+ setDrawerOpen(true)} /> +
+ + setDrawerOpen(false)} + title="Files" + > + + + +
+
+ +
+
+ setDrawerOpen(true)} + /> +
+
+
+ ); +} diff --git a/apps/web/src/domains/workspace/components/workspace-file-viewer.tsx b/apps/web/src/domains/workspace/components/workspace-file-viewer.tsx new file mode 100644 index 00000000000..4e986f1262f --- /dev/null +++ b/apps/web/src/domains/workspace/components/workspace-file-viewer.tsx @@ -0,0 +1,764 @@ +/** + * Viewer for individual workspace files. Supports markdown (preview/source + * toggle), JSON (pretty-printed preview), plain text, images, video, and a + * binary-fallback metadata card. Text-based files can be edited in-place with + * Ctrl+S / Cmd+S to save. + */ + +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Check, + Copy, + FileIcon, + FileText, + FolderOpen, + Image as ImageIcon, + Loader2, + Pencil, + Video, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "@vellum/design-library/components/button"; +import { client } from "@/generated/api/client.gen.js"; +import { FileMarkdown, isMarkdown } from "@/domains/intelligence/components/file-markdown.js"; +import { isJson, prettifyJson } from "@/domains/workspace/utils/file-json.js"; + +import type { WorkspaceViewMode } from "@/domains/workspace/components/workspace-browser.js"; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +interface WorkspaceFileResponse { + name?: string; + path?: string; + size?: number; + mimeType?: string; + modifiedAt?: string; + content?: string; +} + +function workspaceFileRetrieveOptions(opts: { + path: { assistant_id: string }; + query: { path: string }; +}) { + return queryOptions({ + queryFn: async () => { + const { data, error } = await client.get({ + url: "/v1/assistants/{assistant_id}/workspace/file/", + path: opts.path, + query: opts.query, + }); + if (error) throw error; + return data!; + }, + queryKey: ["assistantsWorkspaceFileRetrieve", opts], + }); +} + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +function formatFileSize(bytes: number | undefined): string { + if (bytes == null) return "Unknown size"; + if (bytes < 1024) return `${bytes} bytes`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function FileHeaderIcon({ mimeType }: { mimeType: string }) { + const semi = mimeType.indexOf(";"); + const baseMime = (semi === -1 ? mimeType : mimeType.slice(0, semi)).trim(); + let Icon = FileText; + if (baseMime.startsWith("image/")) Icon = ImageIcon; + else if (baseMime.startsWith("video/")) Icon = Video; + else if ( + !baseMime.startsWith("text/") && + baseMime !== "application/json" && + baseMime !== "application/octet-stream" + ) { + Icon = FileIcon; + } + return ( + + + + ); +} + +function ViewModeToggle({ + viewMode, + onChange, +}: { + viewMode: WorkspaceViewMode; + onChange: (mode: WorkspaceViewMode) => void; +}) { + return ( +
+ {(["preview", "source"] as const).map((mode) => { + const active = viewMode === mode; + return ( + + ); + })} +
+ ); +} + +function FileHeader({ + name, + mimeType, + size, + rightContent, +}: { + name: string; + mimeType: string; + size?: number; + rightContent?: React.ReactNode; +}) { + return ( +
+
+ + + {name} + + {size != null && ( + + {formatFileSize(size)} + + )} +
+ {rightContent} +
+ ); +} + +function BinaryContentViewer({ + assistantId, + path, + mimeType, +}: { + assistantId: string; + path: string; + mimeType: string; +}) { + const { data: blob, isLoading } = useQuery({ + queryFn: async () => { + const res = await client.get({ + url: "/v1/assistants/{assistant_id}/workspace/file/content/", + path: { assistant_id: assistantId }, + query: { path }, + parseAs: "blob", + }); + if (res.error) throw res.error; + return res.data!; + }, + queryKey: ["assistantsWorkspaceFileContentRetrieve", { assistantId, path }], + enabled: !!path, + }); + + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + if (!blob) return; + const url = URL.createObjectURL(blob); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + setObjectUrl(null); + }; + }, [blob]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!objectUrl) return null; + + if (mimeType.startsWith("image/")) { + return ( +
+ {path.split("/").pop() +
+ ); + } + + if (mimeType.startsWith("video/")) { + return ( +
+
+ ); + } + + return null; +} + +function isHiddenPath(path: string): boolean { + return path.split("/").some((segment) => segment.startsWith(".")); +} + +function EditFooter({ + isDirty, + isSaving, + onSave, + onDiscard, +}: { + isDirty: boolean; + isSaving: boolean; + onSave: () => void; + onDiscard: () => void; +}) { + return ( +
+ + {isSaving && ( + + )} + +
+ ); +} + +function ContentActionBar({ + content, + showEdit, + isEditing, + onToggleEdit, +}: { + content: string; + showEdit: boolean; + isEditing: boolean; + onToggleEdit: () => void; +}) { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + const handleCopy = useCallback(() => { + void navigator.clipboard.writeText(content).then(() => { + setCopied(true); + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => setCopied(false), 1500); + }); + }, [content]); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + if (isEditing) { + return null; + } + + return ( +
+ {showEdit && ( +
+ ); +} + +// --------------------------------------------------------------------------- +// Textarea editor — shared across markdown source, JSON source, and plain text +// --------------------------------------------------------------------------- + +const MONO_FONT = + "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace"; + +function FileTextarea({ + value, + onChange, + onSave, +}: { + value: string; + onChange: (value: string) => void; + onSave: () => void; +}) { + return ( +