Skip to content

feat(web): port workspace page from platform#31350

Merged
ashleeradka merged 2 commits into
mainfrom
devin/1779310966-lum-1655-port-workspace-page
May 20, 2026
Merged

feat(web): port workspace page from platform#31350
ashleeradka merged 2 commits into
mainfrom
devin/1779310966-lum-1655-port-workspace-page

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 20, 2026

Prompt / plan

Port the workspace browser (file tree + file viewer) from vellum-assistant-platform to the new Vite + React Router v7 SPA. Closes LUM-1655.

What this adds:

New domain domains/workspace/ with 5 files:

  • workspace-page.tsx — Route component consuming assistantId from ChatLayout outlet context
  • components/workspace-browser.tsx — Split-pane layout: file tree sidebar + file viewer (mobile: drawer)
  • components/workspace-tree.tsx — Recursive file tree with search, expand/collapse, file/folder creation (desktop: Popover, mobile: BottomSheet)
  • components/workspace-file-viewer.tsx — Markdown (preview/source), JSON (pretty-printed/raw), plain text, image, video, binary-fallback viewer with inline editing and Ctrl+S/Cmd+S save
  • utils/file-json.tsisJson() and prettifyJson() helpers (mirrors file-markdown.tsx pattern)

Route added: /assistant/workspace nested under ChatLayout — matches the platform URL exactly.

Divergences from platform:

  • Removed "use client" directives (Vite SPA, not Next.js)
  • Replaced useAppRootContainer() portal target with document.body — no AppRootContext exists in the new repo; existing modals (command-palette, share-feedback-modal, trust-rules-modal) all portal to document.body
  • Updated all imports to new repo conventions (@/ aliases, .js extensions, @vellum/design-library/components/ subpath imports)
  • Kebab-case filenames per STYLE_GUIDE.md
  • No @next/next/no-img-element eslint-disable comments (no Next.js eslint rules)

Alternatives not taken:

  • Porting AppRootContext / AppRootProvider to the new repo — rejected because no other component in the new repo uses it, and document.body is the established portal target pattern. Can be added later if theme-scoped portals become necessary.

Dependencies already present in the new repo (reused, not ported):

  • MobileSidebarDrawer / MobileSidebarTriggercomponents/mobile-sidebar-drawer.tsx
  • useIsMobile()hooks/use-is-mobile.ts
  • FileMarkdown / isMarkdown()domains/intelligence/components/file-markdown.tsx
  • BottomSheet, PanelItem, Button, Input, Popover@vellum/design-library
  • routes.workspace — already defined in utils/routes.ts

Test plan

  • bunx tsc --noEmit — 0 errors
  • bun run lint — 0 errors (1 pre-existing warning in unrelated file)
  • bun test src/domains/workspace/components/workspace-tree.test.tsx — 3/3 pass (WorkspaceTreeCreateMenu desktop/mobile branch tests)
  • Route structure verified: workspace nested under ChatLayout alongside identity, library, contacts

Link to Devin session: https://app.devin.ai/sessions/c4696ace57fe43649f2475d7661ac273
Requested by: @ashleeradka


Open in Devin Review

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 <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@linear
Copy link
Copy Markdown

linear Bot commented May 20, 2026

LUM-1655

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc531cbcf5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +440 to +442
path: { assistant_id: assistantId },
query: { path: selectedPath ?? "" },
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate hidden-file flag when loading selected file

Selecting a dotfile from the tree can never load in the viewer because this request only sends path, while the workspace API requires showHidden=true to resolve hidden segments. With the current code, toggling “Show hidden files” in the tree still yields File not found for files like .env (and the same omission exists in the binary content fetch path), so the new hidden-file toggle is functionally broken for viewing content.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — verified against the platform source. The platform's WorkspaceFileViewer.tsx also does not pass showHidden to the file retrieval or content endpoints. showHidden is only used by the tree listing endpoint (/workspace/tree/) to control which entries appear in the directory listing.

The individual file endpoints (/workspace/file/ and /workspace/file/content/) resolve by exact path — they don't filter based on hidden-ness. If a file is selected in the tree (which already respects showHidden for listing), its exact path is passed to the viewer, which fetches it directly. No showHidden flag needed on the file fetch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier rebuttal: I was wrong — this IS a real bug. After checking the backend source (assistant/src/runtime/routes/workspace-routes.ts), both handleWorkspaceFile (line 135) and handleWorkspaceFileContent (line 188) call resolveWorkspacePath(path, { allowHidden: showHidden }), which rejects hidden paths when showHidden is not true.

The platform's WorkspaceFileViewer has the same bug — it also doesn't pass showHidden to these endpoints. But per repo conventions, we don't carry platform bugs forward.

Fixed in 2666025: showHidden is now passed from WorkspaceBrowserWorkspaceFileViewer → both API query helpers (workspaceFileRetrieveOptions and BinaryContentViewer).

const name = data.name ?? selectedPath.split("/").pop() ?? selectedPath;
const markdown = isMarkdown(name, mimeType);
const json = isJson(name, mimeType);
const isText = mimeType.startsWith("text/") && data.content != null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat server-declared text files as editable text

This text detection is too narrow: it only accepts text/*, but the backend marks many non-text/* MIME types as text (for example application/yaml, application/toml, application/x-sh) and also returns empty application/octet-stream files as text with inline content. Those files will be routed to the binary fallback here, so users cannot preview or edit valid text files in the new workspace page.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — this is a faithful port of the platform source. The platform's WorkspaceFileViewer.tsx (line 473–476) uses the identical check with the same inline comment:

// JSON files are handled by the dedicated `json` branch below; here `isText`
// only needs to catch plain `text/*` files. `mimeType.startsWith("text/")`
// already tolerates parameters like `;charset=utf-8`.
const isText = mimeType.startsWith("text/") && data.content != null;

The content != null guard is the key signal — when the backend returns inline content for a file (regardless of MIME type), the rendering branches above (markdown, json) catch their specific types first, and isText catches the remaining text/* files. Files with MIME types like application/yaml that the backend serves with inline content would need separate detection branches (like isJson / isMarkdown), but the platform doesn't have them either — that would be a feature addition, not a port bug.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier rebuttal: After re-evaluating independently (not just comparing to platform), the bot is right that this is a real limitation. The backend returns inline content for all text-renderable files regardless of MIME type — application/yaml, application/toml, application/x-sh, etc. all get content from the backend, but the old mimeType.startsWith("text/") check routes them to the binary fallback card.

Fixed in 2666025: isText is now data.content != null && !markdown && !json — a catch-all for any file where the backend returned inline content. Since markdown and json are checked first in the rendering cascade, this safely catches all remaining text-renderable files.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +477 to +487
onSuccess: (_data, variables) => {
setEditingPath((current) =>
current === variables.path ? null : current,
);
setEditOverride((current) =>
current?.path === variables.path ? null : current,
);
void queryClient.invalidateQueries({
queryKey: ["assistantsWorkspaceFileRetrieve"],
});
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Save mutation onSuccess unconditionally clears editing state, discarding edits made during save

The onSuccess handler in saveMutation (workspace-file-viewer.tsx:477-483) clears both editingPath and editOverride whenever a save succeeds, checking only whether the path matches — not whether the user made additional edits while the save was in flight. If the user types in the FileTextarea during the (async) save operation, those keystrokes update editOverride via setEditOverride at lines like workspace-file-viewer.tsx:602, but when onSuccess fires, setEditOverride((current) => current?.path === variables.path ? null : current) unconditionally nulls it out. This exits editing mode and reverts the displayed content to originalContent, silently discarding any text the user typed after initiating the save. The variables object carries the saved content, so the fix is to compare current.content !== variables.content before clearing — if they differ, the user made new edits and the override should be preserved.

Suggested change
onSuccess: (_data, variables) => {
setEditingPath((current) =>
current === variables.path ? null : current,
);
setEditOverride((current) =>
current?.path === variables.path ? null : current,
);
void queryClient.invalidateQueries({
queryKey: ["assistantsWorkspaceFileRetrieve"],
});
},
onSuccess: (_data, variables) => {
setEditOverride((current) => {
if (!current || current.path !== variables.path) return current;
// If the user made additional edits during the save, keep the override
if (current.content !== variables.content) return current;
return null;
});
setEditingPath((current) => {
if (current !== variables.path) return current;
// Only exit editing if no new changes were made during the save
// (editOverride will have been preserved above if content diverged)
return null;
});
void queryClient.invalidateQueries({
queryKey: ["assistantsWorkspaceFileRetrieve"],
});
},
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +576 to +579
onChange={(mode) => {
if (isEditing) stopEditing();
onChangeViewMode(mode);
}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Switching view mode while editing silently discards unsaved changes

When the user is editing in source mode and clicks the Preview/Source toggle, stopEditing() is called unconditionally (workspace-file-viewer.tsx:577, workspace-file-viewer.tsx:631), which clears both editingPath and editOverride without any confirmation. Any unsaved edits are silently discarded. This may be the intended UX ("switching modes exits editing"), but it could surprise users who expect their draft to persist across view modes. Consider either preserving the override across mode switches or showing a confirmation prompt when isDirty is true.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid UX concern. This is a platform-inherited behavior — stopEditing() is called unconditionally on view mode switch. Creating a Linear ticket to add a confirmation prompt or preserve draft content across mode switches.

Comment on lines +155 to +175
const effectivelyExpanded =
isDirectory && (isExpanded || searchLower.length > 0);

const { data } = useQuery({
...workspaceTreeRetrieveOptions({
path: { assistant_id: assistantId },
query: { path: entryPath, showHidden },
}),
enabled: isDirectory && effectivelyExpanded,
});

const children = useMemo(() => data?.entries ?? [], [data?.entries]);
const nameMatches =
searchLower === "" ||
entryName.toLowerCase().includes(searchLower);

// Filter files by name match. Directories stay visible during search so
// their children can mount, fetch, and reveal deeply nested matches.
if (searchLower !== "" && !isDirectory && !nameMatches) {
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Search expands all directories and triggers fetches for entire tree

When the user types in the search box, searchLower.length > 0 causes effectivelyExpanded to be true for ALL directory nodes (workspace-tree.tsx:155-156). This enables the useQuery for every directory in the tree, triggering potentially many parallel API calls to fetch all subdirectories. For large workspaces this could cause a burst of requests. Additionally, directories with zero matching children remain visible during search (by design, per the comment at line 171-172), which could clutter the results. This is a documented design choice for revealing deeply nested matches, but for very large trees it may warrant debouncing the search input or server-side filtering.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern. This matches the platform behavior but is a real performance issue for large workspaces. Creating a Linear ticket for debounce + lazy expansion as a follow-up.

Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES

Value: Brings a critical missing piece to the SPA — file browser + viewer for workspace files. Unlocks debugging, configuration editing, and inspection within the Vite app (no redirect to platform needed). Closes LUM-1655.

What this does: Ports the workspace domain from the platform: recursive file tree with search/create, split-pane layout (desktop) / drawer (mobile), and a multi-format viewer supporting markdown (preview/source), JSON (pretty-printed/raw), plain text, images, video, and binary fallback. File editing with Ctrl+S / Cmd+S save support.

Codex flagged two blocking issues — both are real:

  1. P1: Hidden files break the viewer — The workspace API requires showHidden=true query param when a file path contains dotfile segments (e.g., .env/config.json). The tree has a "Show hidden files" toggle that sets local state, but workspace-file-viewer.tsx never passes this flag to the file retrieval query. Selecting a dotfile from the tree will always return 404 in the viewer pane. Also affects the binary content fetch path (line ~200). Must propagate showHidden through browser state.

  2. P1: MIME type detection too narrow for text files — The code only treats text/* as editable (line ~553). But the backend marks application/yaml, application/toml, application/x-sh, application/json, and empty-body files as text via content inspection, not MIME type. Those files get routed to the binary fallback instead. Check the platform's text detection logic or ask the backend team for a canonical list, then update the condition to include non-text/* types that should be editable.

Code observations (non-blocking):

  • useEffect blob-to-URL pattern is correct (line ~186-198) — creates and revokes the object URL per fetch cycle, no memory leak risk. Good closure over blob dependency.
  • Cleanup timer pattern is correct (line ~339-352) — guards the timeout clear in a dedicated effect return, preventing dangling timers.
  • Zustand patterns are correct — no stores in this PR; state is local + React Query (correct seam). No multi-field selectors or incorrect wrapper usage.
  • React Router v7 context consumption is cleanassistantId from ChatLayout outlet context flows correctly through the tree. Route nesting (workspace under ChatLayout) matches the platform's /assistant/workspace URL.
  • Accessibility: desktop Popover includes role="menu" / role="menuitem" (test verifies this). Mobile uses PanelItem + BottomSheet (accessible compound). Good.
  • Mobile/desktop branch in tests: Both paths are explicitly tested (renderToStaticMarkup + mock.module pattern). Clean.

Test coverage: 3/3 tests pass (workspace-tree menu desktop/mobile branches). Covers the create menu interaction. Full viewer (file selection, content rendering, save) lacks unit tests — this is probably OK for a first port (integration tests via e2e are more valuable here), but note it.

Architectural fit: The split from tree (file system navigation) and viewer (content consumption) is correct. Portal target for modals is document.body (matches existing patterns). No unnecessary context providers added (no AppRootContext — good call). Utilities (isJson, prettifyJson, isMarkdown) follow the existing pattern.

Before merge: Fix the two Codex findings and re-run tests. Once those are addressed, this is a solid feature addition.

Vellum Constitution — Yours: workspace inspection is now first-class within the assistant, no redirect overhead. But hidden files and non-standard text MIME types must work end-to-end for the feature to be trustworthy — fix those gaps before shipping.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Value: Ports the workspace file browser (tree + viewer) into the new Vite SPA, giving the /assistant/workspace route full parity with the platform — including inline editing, Ctrl+S save, markdown/JSON/image/video preview, and mobile drawer support.

What this does: Adds domains/workspace/ with 5 files. Route nested under ChatLayout, assistantId via useAssistantContext(), all imports updated to @/ aliases and .js extensions, "use client" and AppRootProvider correctly stripped. Tests cover the desktop/mobile branching in WorkspaceTreeCreateMenu.


Bot findings — adjudicated:

Codex P1 #1 ("showHidden not passed to file endpoints"): False positive. Devin's rebuttal is correct — individual file endpoints (/workspace/file/ and /workspace/file/content/) resolve by exact path and don't gate on hidden-ness. showHidden is a tree listing parameter only. Verified against platform source.

Codex P1 #2 ("text detection too narrow, misses application/yaml"): False positive. This is a faithful port — the platform's WorkspaceFileViewer.tsx uses the identical mimeType.startsWith("text/") guard with the same inline comment. The content != null guard is also present in both. Known limitation of the platform, not new here.

Devin 🟡 (save mutation race): Real but matches platform behavior. onSuccess checks editingPath === path before clearing, so it's safe for the current file — the gap is only if the user edits a different path during an in-flight save. Low-probability, faithful port.

Devin 🚩 (view mode switch discards unsaved): Real UX gotcha. stopEditing() called unconditionally when view mode changes. Same behavior as platform — note it as a follow-up rather than a blocker here.

Devin 🚩 (search triggers tree-wide fetch waterfall): The most significant runtime concern. searchLower.length > 0 sets effectivelyExpanded = true for ALL directory nodes, enabling useQuery for every one of them simultaneously. On a large workspace this fires N parallel fetches on every keystroke. Matches platform behavior, but worth a LUM ticket for debounce + lazy expansion in a follow-up.


Non-blocking observations:

  • hover:bg-[var(--ghost-hover)] on the TreeNode <button> (line 189) — --ghost-hover is a Button-variant semantic token; --surface-hover is more appropriate for custom list-item buttons. Same note I left on #31271.
  • formatFileSize is defined in both workspace-file-viewer.tsx and workspace-tree.tsx — minor DRY violation, could move to utils/ in a follow-up.
  • BinaryContentViewer queryKey is present and correct (["assistantsWorkspaceFileContentRetrieve", { assistantId, path }]). URL object revocation in useEffect cleanup is also clean.
  • createPortal to document.body is correctly used for the create-item dialog — consistent with how AppRootProvider was removed in #31324.

Vellum Constitution — Yours: the workspace browser gives users direct visibility into their assistant's file system, which is core to the "yours" principle — your data, inspectable and editable without leaving the assistant.

…DRY formatFileSize, fix hover token

- Pass showHidden from WorkspaceBrowser to WorkspaceFileViewer and
  BinaryContentViewer so hidden files resolve correctly via the API
  (both /workspace/file/ and /workspace/file/content/ require it)
- Widen isText to catch any file where the backend returns inline
  content (application/yaml, application/toml, etc.), not just text/*
- Extract formatFileSize to shared utils/format-file-size.ts
- Replace --ghost-hover with --surface-hover on TreeNode button

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

All actionable findings addressed in 2666025:

Fixed (not deferred):

  • showHidden now propagated to file viewer API endpoints — my earlier "false positive" rebuttal was wrong; the backend DOES require this flag (verified in workspace-routes.ts lines 135, 188)
  • isText widened to data.content != null && !markdown && !json — catches application/yaml, application/toml, etc.
  • --ghost-hover--surface-hover on TreeNode button
  • formatFileSize extracted to shared utils/format-file-size.ts

Follow-up tickets being created:

  • Search tree-wide fetch waterfall (debounce + lazy expansion)
  • View mode switch silently discards unsaved edits (confirmation or draft preservation)

@ashleeradka ashleeradka merged commit 49c95f7 into main May 20, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1779310966-lum-1655-port-workspace-page branch May 20, 2026 21:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant