feat(web): port AssistantShell layout from platform repo#31085
Conversation
Port the assistant app shell (sidebar + header + mobile drawer) from the platform repo to provide the proper layout structure that makes the web app look like production. Components ported: - AssistantShell: main layout with collapsible sidebar rail, mobile drawer with focus trap, iOS virtual keyboard tracking, Ctrl+ toggle shortcut, Ctrl+K command palette shortcut, Ctrl+[/] nav - AssistantShellHeader: top bar with sidebar toggle, home/search/ back/forward buttons, compose button, responsive mobile layout - SideMenu: basic navigation (Home, Chat, Library, Settings) with NavLink active state highlighting and overlay variant for mobile Utilities ported: - useVisibleViewport: tracks VisualViewport API for iOS keyboard avoidance (pinch-zoom safe, offset tracking) - haptics: no-op stub (Capacitor not yet integrated) Skipped (not needed for web): - OfflineBanner (renders null on non-native platforms) - Layout.tsx outer shell (auth/org/notifications not yet available) Convention compliance: - Removed 'use client' directives (Vite SPA, not Next.js) - .js extensions on all imports (NodeNext resolution) - React Router useNavigate instead of Next.js router - Ctrl+K/Ctrl+[ labels instead of Cmd (Linux/web context) Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa5f550dcb
ℹ️ 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".
| /> | ||
|
|
||
| {isMobile ? ( | ||
| <main className="relative flex min-w-0 flex-1 min-h-0 overflow-hidden"> |
There was a problem hiding this comment.
Restore scrolling for mobile shell content
On mobile, this <main> owns the remaining viewport but uses overflow-hidden without any vertical scroll container, unlike the desktop branch below. Routes such as HomePage render a normal content <div> with a feed, so when the feed is taller than the viewport it gets clipped and the user cannot scroll to the lower items/actions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is the same pattern used in the platform repo's production AssistantShell.tsx (line 497). The overflow-hidden on the mobile <main> is intentional — each child route page manages its own scroll container. This prevents double scrollbars and gives individual pages control over their scroll behavior (e.g., chat pages use virtual scroll, home pages use their own scroll regions). No change needed.
| isHomeActive={isHomeActive} | ||
| canGoBack={window.history.length > 1} | ||
| onGoBack={() => navigate(-1)} | ||
| onGoForward={() => navigate(1)} |
There was a problem hiding this comment.
Pass forward availability to keep navigation usable
This wires onGoForward, but canGoForward is never passed, so the header receives undefined and always renders the forward button disabled (disabled={!canGoForward}). After a user navigates back within the app, the browser has a forward entry and navigate(1) would work, but the new toolbar prevents clicking it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — canGoForward was indeed never passed, leaving the button permanently disabled. Fixed by tracking React Router's internal history.state.idx to determine whether forward entries exist. Pushed in the next commit.
…opover The design library's Popover component imports @radix-ui/react-popover, and since the design library is linked via file: with .ts source exports, TypeScript resolves through the source and needs the package available in apps/web's node_modules. This was introduced when Popover was added to the design library (#31079) but wasn't caught because that PR didn't touch apps/web/ so pr-web.yaml never ran. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
React Router v7 stores an internal idx in history.state that tracks the current position in the session history stack. By comparing the current idx against the maximum idx seen, we can determine whether forward entries exist and enable/disable the forward button correctly. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
| const onKeyDown = (event: KeyboardEvent) => { | ||
| if (!(event.metaKey || event.ctrlKey) || event.key !== "k") { | ||
| return; | ||
| } | ||
| event.preventDefault(); | ||
| onToggleCommandPalette(); |
There was a problem hiding this comment.
🟡 Ctrl+K shortcut missing shouldHandleShortcut guard — fires inside input/textarea/contenteditable
The Ctrl/Cmd+K shortcut handler (lines 203–208) checks only for the modifier key and event.key === "k", then unconditionally calls event.preventDefault() and onToggleCommandPalette(). In contrast, the Ctrl+\ shortcut (assistant-shell.tsx:186) and Ctrl+[/] shortcuts (assistant-shell.tsx:222) both use the shouldHandleShortcut() helper which returns false when document.activeElement is an INPUT, TEXTAREA, SELECT, or contenteditable element. When onToggleCommandPalette is wired up, pressing Ctrl+K while typing in any text field will prevent the default browser behavior and trigger the command palette instead of performing the expected text-editing action.
| const onKeyDown = (event: KeyboardEvent) => { | |
| if (!(event.metaKey || event.ctrlKey) || event.key !== "k") { | |
| return; | |
| } | |
| event.preventDefault(); | |
| onToggleCommandPalette(); | |
| const onKeyDown = (event: KeyboardEvent) => { | |
| if (!shouldHandleShortcut(event, document.activeElement, "k")) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| onToggleCommandPalette(); | |
| }; |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good catch — this is actually a pre-existing bug in the platform source too (same pattern at AssistantShell.tsx:298-303). Fixed now to use shouldHandleShortcut consistently with the other shortcuts. Pushed.
| const handleStartNewConversation = useCallback(() => { | ||
| navigate("/"); | ||
| }, [navigate]); |
There was a problem hiding this comment.
🚩 Forward button is always disabled because canGoForward is never passed
In apps/web/src/App.tsx:26-28, canGoBack is passed but canGoForward is omitted. Since AssistantShellHeaderProps.canGoForward is boolean | undefined, it defaults to undefined, and disabled={!canGoForward} at assistant-shell-header.tsx:106 evaluates to true. The forward button is therefore permanently disabled in the UI. However, the keyboard shortcut Ctrl+] at assistant-shell.tsx:228 still calls onGoForward (which IS passed as () => navigate(1)), creating an inconsistency where forward navigation works via keyboard but not via the button. This appears to be an intentional limitation since the web History API provides no standard way to determine if forward navigation is possible, but the permanently grayed-out button may confuse users.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Already fixed in the previous commit — canGoForward is now wired using React Router's internal history.state.idx to track whether forward entries exist.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
…ortcut The Ctrl+K handler was missing the shouldHandleShortcut guard that the other shortcuts (Ctrl+\, Ctrl+[/]) use. This caused it to fire inside input/textarea/contenteditable elements, preventing default browser text-editing behavior. This bug exists in the platform source too. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
- Import order: external → alias (@/) → relative per STYLE_GUIDE.md - Use @/ alias for cross-module imports instead of deep relative paths - Destructured React type imports (ReactNode) instead of React.ReactNode - Remove dead typeof window === 'undefined' guards (Vite SPA, window always defined) - Wrap inline arrow functions in useCallback to prevent unnecessary re-renders - Mobile main: overflow-hidden → overflow-y-auto so child routes can scroll Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
Review from vex-assistant-bot:
Clean port. The PR description correctly identifies three pre-existing bugs from the platform source and fixes them in this port (canGoForward wiring, Ctrl+K guard, mobile main overflow) — that's the right approach for a port PR.
Architecture:
AssistantShellcleanly separates desktop rail from mobile drawer with focus trap + Escape-close ✅- iOS keyboard avoidance via
useVisibleViewport(VisualViewport API with bothresizeANDscrolllisteners) ✅ — correct for iOS Safari where one fires without the other during a single keyboard transition - Haptics stub as no-op until Capacitor native bridge wires up — sensible scaffolding ✅
- React Router NavLink with active state highlighting ✅
Convention alignment (all clean):
@/alias paths instead of deep relative imports ✅.jsextensions on TS imports per AGENTS.md ✅- Destructured
type ReactNodeinstead ofReact.ReactNode✅ useCallbackwrapping for nav handlers (prevents re-renders) ✅- Dead
typeof window === "undefined"SSR guards removed (correct — Vite SPA,windowalways defined) ✅
Issue resolution:
- Codex P2
canGoForwardnot passed → Devin fixed viawindow.history.state.idxtracking ✅ - Devin P2 Ctrl+K missing
shouldHandleShortcutguard → fixed ✅ - Codex P2 mobile
<main>overflow-hidden → addressed in PR body (changed tooverflow-y-auto) - Devin pushed back on the mobile overflow concern citing platform precedent — but the PR body says the port changed it to
overflow-y-autofor consistency. Verify which it actually is at73e8c280(the PR description and Devin's reply contradict each other on this point).
Anti-pattern audit (data/codebase/anti-patterns-web.md):
- No fire-and-forget async without try/catch + cancelled guard ✅
useEffectcleanups all return removal functions ✅- No
URLSearchParamsmutation issues ✅ - No barrel files added ✅
- No Context+useReducer cross-component state ✅
- No render-phase side effects ✅
Minor observations (not blocking):
App.tsxpasses bothcanGoBackandcanGoForwardnow (per Devin's fix). Spot-check that thehistory.state.idxtracking is set up before the first navigation event (some browsers don't populateidxon initial load — should be safe with React Router v7, but worth a quick scroll-through).- The 388-line
assistant-shell.tsxis dense but readable. If it grows further, splitting out the mobile drawer logic into its own component would be reasonable. Not necessary now.
CI: Lint, Type Check & Build = success. Socket Security all clear.
Ready for merge once a second human approval lands. Mobile overflow contradiction is the only thing to verify before merge.
|
Thanks for the thorough review. Addressing the mobile overflow contradiction: The code is correct — line 332 has The contradiction was between my earlier reply (where I initially cited platform precedent as a reason not to change it — which was wrong, and the user correctly called me out on it) and the PR body (which documents the fix). I updated the code to Re: |
Prompt / plan
Port the AssistantShell layout components from
vellum-assistant-platformto provide the production app shell structure (collapsible sidebar, header bar with navigation, keyboard shortcuts, mobile drawer).What changed
Ports the app shell from the platform repo and applies assistant repo conventions:
New files:
components/shell/assistant-shell.tsx— Core layout: collapsible sidebar rail (desktop), mobile drawer with focus trap + Escape-close, iOS virtual keyboard tracking via VisualViewport API, keyboard shortcuts (Ctrl+, Ctrl+K, Ctrl+[/])components/shell/assistant-shell-header.tsx— Top bar: sidebar toggle, back/forward/home nav buttons, search + compose buttons, responsive desktop/mobile variantscomponents/shell/side-menu.tsx— Navigation using React Router NavLink with active state highlighting, rail (desktop) and overlay (mobile) variantshooks/use-visible-viewport.ts— iOS keyboard avoidance hook trackingwindow.visualViewportfor proper layout sizing when soft keyboard appearsutils/haptics.ts— Capacitor haptic feedback stub (no-op until native integration)Modified files:
App.tsx— Replaced bare header/main with AssistantShell wrapper, wired navigation callbacks and history tracking for back/forwardpackage.json— Added@radix-ui/react-popover(required by design library's Popover, used by shell)Bug fixes included
canGoForwardwas never wired — App.tsx passedonGoForwardbut notcanGoForward, so the forward button was permanently disabled. Fixed by tracking React Router'swindow.history.state.idxacross location changes to determine forward availability. This bug also exists in the platform source.Ctrl+K shortcut missing input guard — The Ctrl+K handler for command palette didn't call
shouldHandleShortcut(), so pressing Ctrl+K in a text field would fire the command palette instead of allowing normal text input. Fixed to use the same guard as Ctrl+\ and Ctrl+[/]. This bug also exists in the platform source.Mobile
<main>usedoverflow-hidden— The desktop layout correctly usesoverflow-y-autoon<main>so child routes can scroll, but the mobile layout usedoverflow-hiddenwhich clips tall content. Changed tooverflow-y-autofor consistency. The platform source has this same issue.Convention alignment
All ported files were audited against
STYLE_GUIDE.mdandCONVENTIONS.md:@/) → relative, per STYLE_GUIDE.md@/alias: Cross-module imports use@/hooks/...,@/utils/...instead of deep relative paths like../../hooks/...import { type ReactNode } from "react"instead ofReact.ReactNodetypeof window === "undefined"guards: This is a Vite SPA wherewindowis always defined. The platform used these for Next.js SSR compatibility. Removed 6 instances acrossassistant-shell.tsxanduse-visible-viewport.ts.useCallbackfor all handlers: Wrapped inline arrow functions (() => navigate(-1)) inuseCallbackto prevent unnecessary re-rendersTest plan
bun run lint— zero errorsbunx tsc --noEmit— passesLink to Devin session: https://app.devin.ai/sessions/536ceadb023a4059908b0609b9833bc1
Requested by: @ashleeradka