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 (
+
+

+
+ );
+ }
+
+ 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 && (
+ }
+ onClick={onToggleEdit}
+ aria-label="Edit file"
+ className="hover:bg-[var(--surface-base)]"
+ />
+ )}
+ : }
+ onClick={handleCopy}
+ aria-label={copied ? "Copied" : "Copy file contents"}
+ className="hover:bg-[var(--surface-base)]"
+ />
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
{data.modifiedAt && (