Skip to content

Conversation

franknoirot
Copy link
Contributor

@franknoirot franknoirot commented Oct 15, 2025

This PR implements a system for layout in the modeling scene. Towards #8044, closes #8215.

Previous system

Until now, we have had an adhoc layout “system” accumulated from the initial modeling app prototype. A layout consisted of one modeling scene flanked by a sidebar on either side, which each contained a set of panes and actions. Users could toggle panes opened or closed, and this would be persisted to localStorage, but the width of those panes would not be, and vertical resizing between two open panes was not possible. There was certain to be one of each item in the system: one scene, one code pane, one text-to-cad pane, etc.

Why we’re doing this

Several of our goals for ZDS require a more general approach:

  1. Support for multiple views into the same modeling scene
  2. Support for multiple code buffers
  3. Support for third-party extensions to ZDS
  4. Ability to rearrange, add and remove panes
  5. Ability to reassign a pane to different type
  6. Support for full layout configuration persistence
    This PR does not implement any of these but the 6th item, but lays a foundation in preparation for the first 5 and more. Please alert me if there is work in here that jeopardizes those or any other future product goals that touch on layout.

Definitions

  1. Layout: a configuration of nested Split, Tab, Pane, and Simple layouts
  2. Area: the leaf nodes of a Layout and the only descendent of the Simple Layout, which has a type which governs its behavior. Eg: Modeling Scene, File Explorer, Feature Tree. Simple Layout configs only specify an areaType key, and then the application finds the corresponding component from a provided areaLibrary at runtime.
    1. In future, we will allow extensions to register new areaTypes. For now, area types are hardcoded and type-checked in areaTypeRegistry.ts
  3. Action: a fire-and-forget command and associated UI for firing it. These can be defined and attached to Pane type layouts only. Similar to Areas, these are stored only as actionType keys, then looked up at runtime.
  4. Split Layout: a Layout consisting of 2 or more divided child Layouts, which can be inline or block directed, which corresponds to horizontal and vertical in English browsers. A Split creates UI for resizing the Areas in the split
  5. Tab Layout: a special kind of Split Layout, which:
    1. have a active item that takes the full area while hiding all inactive items
    2. provides a UI toolbar to manage the child Layouts, which can be set to appear on any side of the Layout.
    3. provide a close button to each Tab, which removes it from the set
  6. Pane Layout: a special kind of Tab Layout, which
    1. can have no active children
    2. provides a close button that only sets the associated item to inactive
      Note: I removed Tab Layout types, utils, and components from this PR, as we have no use for them yet. I think they could be useful for code editor buffers in future.

What to expect while testing

  1. The app should feel basically identical to current layout generally.
    1. Layout persistence is at the user level, not per project
    2. Closing all the panes in a sidebar should collapse it and disable any resize handles between neighboring areas.
  2. New functionality should be limited to:
    1. Vertical resizing between panes
    2. Persistence of pane heights and widths when refreshing
    3. Ability to reset layout to default via native menu and settings dialog
    4. (Disabled but I can enable if the team likes it) a context menu on Pane toolbars to set the toolbar side and split direction. See below for discussion on disabling it.
    5. A new /layout route to test more gnarly layout configurations with no other app systems (best reached in the browser)

Persistence data structure

The layout is persisted as a JSON string with a name and version fields. Name is always default for now, leaving open the possibility of multiple user-defined layouts in future. The version is always v1 for now, allowing us to detect breaking changes early. The layout field contains the actual serialized data on the nested layout types as discussed previously. The default layout is in @src/lib/layout/configs/default.ts. A deeply nested layout definition is available in ./configs/test.ts .

Errata

  1. I would prefer the persistence of the layout to be saved to disk in the desktop app instead of localStorage, following Nadro’s example from the env work, but felt it was out of scope for this large PR. Future work should honor localStorage and migrate desktop users to an on-disk approach.
  2. I would have liked to make this a true isolated module of its own, but the inclusion of the areaTypeRegistry.ts and actionTypeRegistry.ts broke that boundary, so the couple places those are imported are from their files directly instead of from @src/lib/layout. I could move these somewhere else in the code base I think and have a cleaner divide but I felt like that should wait until we have proper extension-like area and action registration.
  3. I would have included my context menu to change the pane toolbar side and split direction if not for two UX problems I don’t feel I could quite address:
    1. How to handle “collapsing” a Pane layout to its toolbar when it is not in-line with its parent Split layout. I opted to simply not collapse it but that felt unsatisfying and I believe will confuse users.
    2. How to better organize/present these options without proliferating the context menus: smaller custom components for side selection, nested context menus, all seemed out-of-scope.
  4. I’m not sure I should have put the id, label, or icon items on the Layout data structure. Most Layout nodes probably won’t ever need a human-readable label: really only Pane children that are Simple Layouts need them (for their tooltips), and I already put other metadata such as the shortcut under the responsibility of the areaTypeRegistry. Or maybe I have that backwards, and what I should be questioning is if shortcut ought to be in the persisted structure in case of multiple of the same area type. TLDR what data goes where—persisted in userland or prescribed by appland—is still a WIP.
  5. Should I have written more unit tests for these utils? I focused on parse tests, but I could buy someone wanting me to make sure utils all have tests too.

Demo

Screenshare.-.2025-10-16.5_01_44.PM-compressed.mp4

@vercel
Copy link

vercel bot commented Oct 15, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
modeling-app Ready Ready Preview Comment Oct 23, 2025 2:20pm

We won't use it for now so we should come back and add it in when it's
not dead code
Copy link
Contributor Author

@franknoirot franknoirot left a comment

Choose a reason for hiding this comment

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

A little bit of self-review in narrative form to help the brave souls who review for me. Thanks and apologies for the size of this PR!

type: LayoutType.Simple
areaType: AreaType
}
export type Layout = SimpleLayout | SplitLayout | PaneLayout
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is where to start. Layout is a recursive structure of split layouts, pane layouts (which are an extension around splits), and simple layouts or "areas".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Recursively parsing a layout from a JSON string can fail for a number of reasons. If it fails in a way that we can "heal", we do that, but if not we return an error so that higher levels can decide what to do. This could be made more sophisticated in the future (or now if we think it's needed), but for example a Split layout will drop any children that return errors while parsing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The parsing utils got their own file because I felt they were specialized enough, but this utils contains helper functions for loading layouts from storage, for manipulating a layout, and even for creating Tailwind CSS classes based on layout data, so it just got called "utils".

Please let me know if you'd like to see any unit tests for functions in this file. If you don't see it as critical, I will make an issue to add more when I find time.

togglePane: () => {},
/** Kind of a feature flag, remove in future */
enableContextMenus: false,
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The <LayoutRootNode> component wraps the recursive <LayoutNode> component in a context that includes all the mutation methods for the current layout (which is not stored here, but rather in appActor, to be discussed below), and the area and action libraries that connect the areaType and actionType properties within a layout to actual implementations.

This allows us to decouple the layout configuration from the implementations, which you can see demonstrated in <TestLayout>, which provides a testAreaLibrary instead of the defaultAreaLibrary, and provides no actionLibrary at all, leaning back on the nullActionLibrary defined above.

>
{layout.children.map((a, i, arr) => {
const disableResize = !shouldEnableResizeHandle(a, i, arr)
const disableFlex = shouldDisableFlex(a, layout)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a problematic trick I've used to opt out of the split area sizing while a Pane layout within a Split layout is collapsed (having no activeIndices). While it does the trick nicely of locking the size to the toolbar (since react-resizable-panels uses flexbox for its sizing), it creates a slightly imprecise resizing behavior on the resize handles that are still present, and definitely feels like a hack.

This is one of my misgivings with how I've done this: because a Pane layout can expand and collapse, it needs to propagate its resizing "upwards" to its parent Split layout in our conventional "sidebar"-style arrangement. This leads to oddly specific mutations for toggling, and oddly specific layout logic like disableFlex here. But I do find the nesting structure, and the types within it, very powerful and intuitive besides that. I'm interested if anyone has thoughts on this.

engineCommandManager: engineCommandManager,
sceneInfra: sceneInfra,
sceneEntitiesManager: sceneEntitiesManager,
layout: defaultLayout,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The layout is no longer stored as a part of modelingMachine's context, but rather as a part of appActor's. Persistence happens as an action alongside setting a new layout. Getters and setters are exported for the rest of the app to make use of, which App.tsx does.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that TestLayout makes no use of appActor, providing its own simpler local state management.

import type { Options } from 'react-hotkeys-hook'
import { useHotkeys } from 'react-hotkeys-hook'

import { codeManager } from '@src/lib/singletons'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The import of codeManager here is what forced me to break out hotkeys.ts into its own file, because I needed the hotkeyDisplay utility without the circular dependency hell that comes with codeManager.

Comment on lines +299 to +311
function saveLayoutInner({ layout, layoutName = 'default' }: ISaveLayout) {
if (!globalThis.localStorage) {
return
}
globalThis.localStorage.setItem(
`${LAYOUT_PERSIST_PREFIX}${layoutName}`,
globalThis.JSON?.stringify({
version: 'v1',
layout,
} satisfies LayoutWithMetadata)
)
}
export const saveLayout = throttle(saveLayoutInner, LAYOUT_SAVE_THROTTLE)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As mentioned in the errata in the PR description, in a future PR I'd like the desktop app to target a config folder on-disk instead of localStorage, much like our env configs are stored.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This function was used in one place and was briefly frustrating my util tests so I removed it. I believe I fixed what was actually causing the issue due to re-export behavior differences in Vitest, but I decided to leave this change in because we just don't need this code anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove redundant word "pane" from pane titles

4 participants