diff --git a/package-lock.json b/package-lock.json
index d9d86baf71..a845afb2aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -39625,6 +39625,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-resizable-panels": {
+ "version": "0.0.55",
+ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.55.tgz",
+ "integrity": "sha512-J/LTFzUEjJiqwSjVh8gjUXkQDA8MRPjARASfn++d2+KOgA+9UcRYUfE3QBJixer2vkk+ffQ4cq3QzWzzHgqYpQ==",
+ "dev": true,
+ "peerDependencies": {
+ "react": "^16.14.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-resize-detector": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
@@ -48067,6 +48077,7 @@
"react-i18next": "^11.18.6",
"react-linkify": "^1.0.0-alpha",
"react-refresh": "^0.14.0",
+ "react-resizable-panels": "^0.0.55",
"react2angular": "^4.0.6",
"react2angular-shared-context": "^1.1.0",
"requirejs": "^2.3.6",
@@ -56670,6 +56681,7 @@
"react-i18next": "^11.18.6",
"react-linkify": "^1.0.0-alpha",
"react-refresh": "^0.14.0",
+ "react-resizable-panels": "^0.0.55",
"react2angular": "^4.0.6",
"react2angular-shared-context": "^1.1.0",
"recurly": "^4.0.0",
@@ -81001,6 +81013,13 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
"integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ=="
},
+ "react-resizable-panels": {
+ "version": "0.0.55",
+ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.55.tgz",
+ "integrity": "sha512-J/LTFzUEjJiqwSjVh8gjUXkQDA8MRPjARASfn++d2+KOgA+9UcRYUfE3QBJixer2vkk+ffQ4cq3QzWzzHgqYpQ==",
+ "dev": true,
+ "requires": {}
+ },
"react-resize-detector": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 65165f3780..4c3531654e 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -873,9 +873,14 @@ const ProjectController = {
!Features.hasFeature('saas') ||
req.query?.personal_access_token === 'true'
+ const idePageReact = req.query?.['ide-page'] === 'react'
+
const template =
detachRole === 'detached'
- ? 'project/editor_detached'
+ ? // TODO: Create React version of detached page
+ 'project/editor_detached'
+ : idePageReact
+ ? 'project/ide-react'
: 'project/editor'
res.render(template, {
diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug
index cb976e3fbe..9eb19aee12 100644
--- a/services/web/app/views/project/editor/meta.pug
+++ b/services/web/app/views/project/editor/meta.pug
@@ -41,6 +41,7 @@ meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChang
meta(name="ol-mathJax3Path" content=mathJax3Path)
meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
meta(name="ol-projectTags" data-type="json" content=projectTags)
+meta(name="ol-idePageReact", data-type="boolean" content=idePageReact)
- var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)
diff --git a/services/web/app/views/project/ide-react.pug b/services/web/app/views/project/ide-react.pug
new file mode 100644
index 0000000000..d1f6aa675c
--- /dev/null
+++ b/services/web/app/views/project/ide-react.pug
@@ -0,0 +1,21 @@
+extends ../layout
+
+block vars
+ - var suppressNavbar = true
+ - var suppressFooter = true
+ - var suppressSkipToContent = true
+ - metadata.robotsNoindexNofollow = true
+
+block entrypointVar
+ - entrypoint = 'pages/ide'
+
+block content
+ main#ide-root
+
+block append meta
+ include ./editor/meta
+
+block prepend foot-scripts
+ each file in (useOpenTelemetry ? entrypointScripts("tracing") : [])
+ script(type="text/javascript", nonce=scriptNonce, src=file)
+ script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 6e146d10ea..a98a8cb883 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -926,6 +926,7 @@
"resend_group_invite": "",
"resend_managed_user_invite": "",
"resending_confirmation_email": "",
+ "resize": "",
"resolve": "",
"resolved_comments": "",
"restore_file": "",
@@ -1178,6 +1179,10 @@
"toolbar_table_insert_table_lowercase": "",
"toolbar_toggle_symbol_palette": "",
"toolbar_undo": "",
+ "tooltip_hide_filetree": "",
+ "tooltip_hide_pdf": "",
+ "tooltip_show_filetree": "",
+ "tooltip_show_pdf": "",
"total_per_month": "",
"total_per_year": "",
"total_with_subtotal_and_tax": "",
diff --git a/services/web/frontend/js/features/ide-react/components/ide-root.tsx b/services/web/frontend/js/features/ide-react/components/ide-root.tsx
new file mode 100644
index 0000000000..8619fb8601
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/ide-root.tsx
@@ -0,0 +1,22 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import IdePage from '@/features/ide-react/components/layout/ide-page'
+
+function IdeRoot() {
+ // Check that we haven't inadvertently loaded Angular
+ // TODO: Remove this before rolling out this component to any users
+ if (typeof window.angular !== 'undefined') {
+ throw new Error('Angular detected. This page must not load Angular.')
+ }
+
+ const { isReady } = useWaitForI18n()
+
+ if (!isReady) {
+ return null
+ }
+
+ return
+}
+
+export default withErrorBoundary(IdeRoot, GenericErrorBoundaryFallback)
diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
new file mode 100644
index 0000000000..c9d24bf061
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
@@ -0,0 +1,11 @@
+import LayoutWithPlaceholders from '@/features/ide-react/components/layout/layout-with-placeholders'
+
+// This is filled with placeholder content while the real content is migrated
+// away from Angular
+export default function IdePage() {
+ return (
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/layout-with-placeholders.tsx b/services/web/frontend/js/features/ide-react/components/layout/layout-with-placeholders.tsx
new file mode 100644
index 0000000000..f3f2d175f8
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/layout-with-placeholders.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react'
+import PlaceholderHeader from '@/features/ide-react/components/layout/placeholder/placeholder-header'
+import PlaceholderChat from '@/features/ide-react/components/layout/placeholder/placeholder-chat'
+import PlaceholderHistory from '@/features/ide-react/components/layout/placeholder/placeholder-history'
+import PlaceholderEditorMainContent from '@/features/ide-react/components/layout/placeholder/placeholder-editor-main-content'
+import MainLayout from '@/features/ide-react/components/layout/main-layout'
+
+export default function LayoutWithPlaceholders({
+ shouldPersistLayout,
+}: {
+ shouldPersistLayout: boolean
+}) {
+ const [chatIsOpen, setChatIsOpen] = useState(false)
+ const [historyIsOpen, setHistoryIsOpen] = useState(false)
+ const [leftColumnDefaultSize, setLeftColumnDefaultSize] = useState(20)
+
+ const headerContent = (
+
+ )
+ const chatContent =
+ const mainContent = historyIsOpen ? (
+
+ ) : (
+
+ )
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx b/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx
new file mode 100644
index 0000000000..045d0f27cf
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx
@@ -0,0 +1,66 @@
+import { Panel, PanelGroup } from 'react-resizable-panels'
+import { ReactNode } from 'react'
+import { HorizontalResizeHandle } from '../resize/horizontal-resize-handle'
+import useFixedSizeColumn from '@/features/ide-react/hooks/use-fixed-size-column'
+import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
+
+const CHAT_DEFAULT_SIZE = 20
+
+type PageProps = {
+ headerContent: ReactNode
+ chatContent: ReactNode
+ mainContent: ReactNode
+ chatIsOpen: boolean
+ shouldPersistLayout: boolean
+}
+
+// The main area below the header is split into two: the main content and chat.
+// The reason for not splitting the left column containing the file tree and
+// outline here is that the history view has its own file tree, so it is more
+// convenient to replace the whole of the main content when in history view.
+export default function MainLayout({
+ headerContent,
+ chatContent,
+ mainContent,
+ chatIsOpen,
+ shouldPersistLayout,
+}: PageProps) {
+ const { fixedPanelRef: chatPanelRef, handleLayout } = useFixedSizeColumn(
+ CHAT_DEFAULT_SIZE,
+ chatIsOpen
+ )
+
+ useCollapsiblePanel(chatIsOpen, chatPanelRef)
+
+ return (
+
+ {headerContent}
+
+
+
+ {mainContent}
+
+ {chatIsOpen ? (
+ <>
+
+
+ {chatContent}
+
+ >
+ ) : null}
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-chat.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-chat.tsx
new file mode 100644
index 0000000000..edb7f7a461
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-chat.tsx
@@ -0,0 +1,7 @@
+import React from 'react'
+
+export default function PlaceholderChat() {
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx
new file mode 100644
index 0000000000..00c4ef36ae
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx
@@ -0,0 +1,90 @@
+import React, { useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+ ImperativePanelHandle,
+ Panel,
+ PanelGroup,
+} from 'react-resizable-panels'
+import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
+import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
+import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
+import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
+
+type PlaceholderEditorAndPdfProps = {
+ shouldPersistLayout?: boolean
+}
+
+export default function PlaceholderEditorAndPdf({
+ shouldPersistLayout = false,
+}: PlaceholderEditorAndPdfProps) {
+ const { t } = useTranslation()
+ const [pdfIsOpen, setPdfIsOpen] = useState(false)
+ const [symbolPaletteIsOpen, setSymbolPaletteIsOpen] = useState(false)
+
+ const pdfPanelRef = useRef(null)
+ useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
+
+ return (
+
+
+
+
+ Editor placeholder
+
+
+
+ {symbolPaletteIsOpen ? (
+ <>
+
+
+
+ Symbol palette placeholder
+
+
+ >
+ ) : null}
+
+
+
+ setPdfIsOpen(pdfIsOpen)}
+ tooltipWhenOpen={t('tooltip_hide_pdf')}
+ tooltipWhenClosed={t('tooltip_show_pdf')}
+ />
+
+ setPdfIsOpen(!collapsed)}
+ >
+ PDF placeholder
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-main-content.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-main-content.tsx
new file mode 100644
index 0000000000..0e1d39beec
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-main-content.tsx
@@ -0,0 +1,38 @@
+import React, { useState } from 'react'
+import TwoColumnMainContent from '@/features/ide-react/components/layout/two-column-main-content'
+import PlaceholderEditorAndPdf from '@/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf'
+import PlaceholderEditorSidebar from '@/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar'
+
+type PlaceholderEditorMainContentProps = {
+ shouldPersistLayout: boolean
+ leftColumnDefaultSize: number
+ setLeftColumnDefaultSize: React.Dispatch>
+}
+
+export default function PlaceholderEditorMainContent({
+ shouldPersistLayout,
+ leftColumnDefaultSize,
+ setLeftColumnDefaultSize,
+}: PlaceholderEditorMainContentProps) {
+ const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
+
+ const leftColumnContent = (
+
+ )
+ const rightColumnContent = (
+
+ )
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx
new file mode 100644
index 0000000000..c6f91c6691
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import { Panel, PanelGroup } from 'react-resizable-panels'
+import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
+
+type PlaceholderHeaderProps = {
+ shouldPersistLayout: boolean
+}
+
+export default function PlaceholderEditorSidebar({
+ shouldPersistLayout,
+}: PlaceholderHeaderProps) {
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx
new file mode 100644
index 0000000000..88ca322af0
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import ChatToggleButton from '@/features/editor-navigation-toolbar/components/chat-toggle-button'
+import HistoryToggleButton from '@/features/editor-navigation-toolbar/components/history-toggle-button'
+
+type PlaceholderHeaderProps = {
+ chatIsOpen: boolean
+ setChatIsOpen: (chatIsOpen: boolean) => void
+ historyIsOpen: boolean
+ setHistoryIsOpen: (chatIsOpen: boolean) => void
+}
+
+export default function PlaceholderHeader({
+ chatIsOpen,
+ setChatIsOpen,
+ historyIsOpen,
+ setHistoryIsOpen,
+}: PlaceholderHeaderProps) {
+ function toggleChatOpen() {
+ setChatIsOpen(!chatIsOpen)
+ }
+
+ function toggleHistoryOpen() {
+ setHistoryIsOpen(!historyIsOpen)
+ }
+
+ return (
+
+ Header placeholder
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx
new file mode 100644
index 0000000000..ff6f3ba4e6
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx
@@ -0,0 +1,38 @@
+import React, { useState } from 'react'
+import TwoColumnMainContent from '@/features/ide-react/components/layout/two-column-main-content'
+
+type PlaceholderHistoryProps = {
+ shouldPersistLayout: boolean
+ leftColumnDefaultSize: number
+ setLeftColumnDefaultSize: React.Dispatch>
+}
+
+export default function PlaceholderHistory({
+ shouldPersistLayout,
+ leftColumnDefaultSize,
+ setLeftColumnDefaultSize,
+}: PlaceholderHistoryProps) {
+ const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
+
+ const leftColumnContent = (
+
+ )
+ const rightColumnContent = (
+ History document diff viewer and versions list placeholder
+ )
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx b/services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx
new file mode 100644
index 0000000000..8deb584dd0
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx
@@ -0,0 +1,80 @@
+import React, { ReactNode, useEffect } from 'react'
+import { Panel, PanelGroup } from 'react-resizable-panels'
+import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
+import { useTranslation } from 'react-i18next'
+import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
+import useFixedSizeColumn from '@/features/ide-react/hooks/use-fixed-size-column'
+import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
+
+type TwoColumnMainContentProps = {
+ leftColumnId: string
+ leftColumnContent: ReactNode
+ leftColumnDefaultSize: number
+ setLeftColumnDefaultSize: React.Dispatch>
+ rightColumnContent: ReactNode
+ leftColumnIsOpen: boolean
+ setLeftColumnIsOpen: (
+ leftColumnIsOpen: TwoColumnMainContentProps['leftColumnIsOpen']
+ ) => void
+ shouldPersistLayout?: boolean
+}
+
+export default function TwoColumnMainContent({
+ leftColumnId,
+ leftColumnContent,
+ leftColumnDefaultSize,
+ setLeftColumnDefaultSize,
+ rightColumnContent,
+ leftColumnIsOpen,
+ setLeftColumnIsOpen,
+ shouldPersistLayout = false,
+}: TwoColumnMainContentProps) {
+ const { t } = useTranslation()
+
+ const {
+ fixedPanelRef: leftColumnPanelRef,
+ fixedPanelWidthRef: leftColumnWidthRef,
+ handleLayout,
+ } = useFixedSizeColumn(leftColumnDefaultSize, leftColumnIsOpen)
+
+ useCollapsiblePanel(leftColumnIsOpen, leftColumnPanelRef)
+
+ // Update the left column default size on unmount rather than doing it on
+ // every resize, which causes ResizeObserver errors
+ useEffect(() => {
+ if (leftColumnWidthRef.current) {
+ setLeftColumnDefaultSize(leftColumnWidthRef.current.size)
+ }
+ }, [leftColumnWidthRef, setLeftColumnDefaultSize])
+
+ return (
+
+ setLeftColumnIsOpen(!collapsed)}
+ >
+ {leftColumnIsOpen ? leftColumnContent : null}
+
+
+ setLeftColumnIsOpen(isOpen)}
+ tooltipWhenOpen={t('tooltip_hide_filetree')}
+ tooltipWhenClosed={t('tooltip_show_filetree')}
+ />
+
+ {rightColumnContent}
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx b/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx
new file mode 100644
index 0000000000..776fdefc56
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx
@@ -0,0 +1,19 @@
+import { PanelResizeHandle } from 'react-resizable-panels'
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
+
+export const HorizontalResizeHandle: FC = ({
+ children,
+ ...props
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/resize/horizontal-toggler.tsx b/services/web/frontend/js/features/ide-react/components/resize/horizontal-toggler.tsx
new file mode 100644
index 0000000000..0ca790f1f5
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/resize/horizontal-toggler.tsx
@@ -0,0 +1,48 @@
+import classNames from 'classnames'
+import Tooltip from '@/shared/components/tooltip'
+
+type HorizontalTogglerType = 'west' | 'east'
+
+type HorizontalTogglerProps = {
+ id: string
+ isOpen: boolean
+ setIsOpen: (isClosed: boolean) => void
+ togglerType: HorizontalTogglerType
+ tooltipWhenOpen: string
+ tooltipWhenClosed: string
+}
+
+export function HorizontalToggler({
+ id,
+ isOpen,
+ setIsOpen,
+ togglerType,
+ tooltipWhenOpen,
+ tooltipWhenClosed,
+}: HorizontalTogglerProps) {
+ const description = isOpen ? tooltipWhenOpen : tooltipWhenClosed
+
+ return (
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx b/services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx
new file mode 100644
index 0000000000..36a58a29a2
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx
@@ -0,0 +1,13 @@
+import { PanelResizeHandle } from 'react-resizable-panels'
+import { useTranslation } from 'react-i18next'
+import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
+
+export function VerticalResizeHandle(props: PanelResizeHandleProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/hooks/use-collapsible-panel.ts b/services/web/frontend/js/features/ide-react/hooks/use-collapsible-panel.ts
new file mode 100644
index 0000000000..ad286284b3
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/hooks/use-collapsible-panel.ts
@@ -0,0 +1,19 @@
+import { RefObject, useEffect } from 'react'
+import { ImperativePanelHandle } from 'react-resizable-panels'
+
+export default function useCollapsiblePanel(
+ panelIsOpen: boolean,
+ panelRef: RefObject
+) {
+ useEffect(() => {
+ const panel = panelRef.current
+ if (!panel) {
+ return
+ }
+ if (panelIsOpen) {
+ panel.expand()
+ } else {
+ panel.collapse()
+ }
+ }, [panelIsOpen, panelRef])
+}
diff --git a/services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts b/services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts
new file mode 100644
index 0000000000..4ca2e32fce
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts
@@ -0,0 +1,66 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { ImperativePanelHandle } from 'react-resizable-panels'
+import { PanelGroupOnLayout } from 'react-resizable-panels/src/types'
+
+export default function useFixedSizeColumn(
+ defaultSize: number,
+ isOpen: boolean
+) {
+ const fixedPanelRef = useRef(null)
+
+ const fixedPanelWidthRef = useRef({ size: defaultSize, pixels: 0 })
+ const [initialLayoutDone, setInitialLayoutDone] = useState(false)
+
+ const measureFixedPanelSizePixels = useCallback(() => {
+ return fixedPanelRef.current?.getSize('pixels') || 0
+ }, [fixedPanelRef])
+
+ const handleLayout = useCallback(
+ sizes => {
+ // Measure the pixel width here because it's not always up to date in the
+ // panel's onResize
+ fixedPanelWidthRef.current = {
+ size: sizes[0],
+ pixels: measureFixedPanelSizePixels(),
+ }
+ setInitialLayoutDone(true)
+ },
+ [measureFixedPanelSizePixels]
+ ) as PanelGroupOnLayout
+
+ useEffect(() => {
+ if (!isOpen) {
+ return
+ }
+
+ // Only start watching for resizes once the initial layout is done,
+ // otherwise we could measure the fixed column while it has zero width and
+ // collapse it
+ if (!initialLayoutDone || !fixedPanelRef.current) {
+ return
+ }
+
+ const fixedPanelElement = document.querySelector(
+ `[data-panel-id="${fixedPanelRef.current.getId()}"]`
+ )
+
+ const panelGroupElement = fixedPanelElement?.closest('[data-panel-group]')
+ if (!panelGroupElement || !fixedPanelElement) {
+ return
+ }
+
+ const resizeObserver = new ResizeObserver(() => {
+ fixedPanelRef.current?.resize(fixedPanelWidthRef.current.pixels, 'pixels')
+ })
+
+ resizeObserver.observe(panelGroupElement)
+
+ return () => resizeObserver.unobserve(panelGroupElement)
+ }, [fixedPanelRef, measureFixedPanelSizePixels, initialLayoutDone, isOpen])
+
+ return {
+ fixedPanelRef,
+ fixedPanelWidthRef,
+ handleLayout,
+ }
+}
diff --git a/services/web/frontend/js/pages/ide.jsx b/services/web/frontend/js/pages/ide.jsx
new file mode 100644
index 0000000000..d3eea73afc
--- /dev/null
+++ b/services/web/frontend/js/pages/ide.jsx
@@ -0,0 +1,13 @@
+// Configure dynamically loaded assets (via webpack) to be downloaded from CDN
+import '../utils/webpack-public-path'
+
+// Set up error reporting, including Sentry
+import '../infrastructure/error-reporter'
+
+import ReactDOM from 'react-dom'
+import IdeRoot from '../features/ide-react/components/ide-root'
+
+const element = document.getElementById('ide-root')
+if (element) {
+ ReactDOM.render(, element)
+}
diff --git a/services/web/frontend/stories/ide-page/layout.stories.tsx b/services/web/frontend/stories/ide-page/layout.stories.tsx
new file mode 100644
index 0000000000..cc30a14a95
--- /dev/null
+++ b/services/web/frontend/stories/ide-page/layout.stories.tsx
@@ -0,0 +1,27 @@
+import LayoutWithPlaceholders from '@/features/ide-react/components/layout/layout-with-placeholders'
+
+export default {
+ title: 'Editor / Page Layout',
+ component: LayoutWithPlaceholders,
+ decorators: [
+ (Story: any) => (
+
+
+
+ ),
+ ],
+}
+
+export const Persisted = {
+ args: {
+ shouldPersistLayout: true,
+ },
+}
+
+export const Unpersisted = {
+ args: {
+ shouldPersistLayout: false,
+ },
+}
diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less
index 8f1da9570f..8182f948e6 100644
--- a/services/web/frontend/stylesheets/app/editor.less
+++ b/services/web/frontend/stylesheets/app/editor.less
@@ -19,6 +19,7 @@
@import './editor/dictionary.less';
@import './editor/compile-button.less';
@import './editor/figure-modal.less';
+@import './editor/ide-react.less';
@ui-layout-toggler-def-height: 50px;
@ui-resizer-size: 7px;
diff --git a/services/web/frontend/stylesheets/app/editor/ide-react.less b/services/web/frontend/stylesheets/app/editor/ide-react.less
new file mode 100644
index 0000000000..0aa7402696
--- /dev/null
+++ b/services/web/frontend/stylesheets/app/editor/ide-react.less
@@ -0,0 +1,90 @@
+#ide-react-page {
+ height: 100vh;
+}
+
+.ide-react-main {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ .toolbar.toolbar-header {
+ position: static;
+ flex-grow: 0;
+ color: var(--neutral-20);
+ }
+}
+
+.ide-react-body {
+ flex-grow: 1;
+}
+
+.horizontal-resize-handle {
+ width: @ui-resizer-size !important;
+ height: 100%;
+
+ // Enable ::before and ::after pseudo-elements to position themselves correctly
+ position: relative;
+
+ background-color: @editor-resizer-bg-color;
+
+ .custom-toggler {
+ padding: 0;
+ border-width: 0;
+ }
+
+ &::before,
+ &::after {
+ // This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
+ // we'd have to change this SVG too
+ content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='18' viewBox='0 0 7 18'%3E%3Cpath d='M2 0h3v3H2zM2 5h3v3H2zM2 10h3v3H2zM2 15h3v3H2z' style='fill:%239da7b7'/%3E%3C/svg%3E");
+ display: block;
+ position: absolute;
+ text-align: center;
+ left: 0;
+ width: 7px;
+ height: 18px;
+ }
+ &::before {
+ top: 25%;
+ }
+ &::after {
+ top: 75%;
+ }
+}
+
+.vertical-resize-handle {
+ height: 6px;
+ background-color: @vertical-resizable-resizer-bg;
+
+ &:hover {
+ background-color: @vertical-resizable-resizer-hover-bg;
+ }
+
+ &::after {
+ // This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
+ // we'd have to change this SVG too
+ content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='6' viewBox='0 0 18 6'%3E%3Cpath d='M0 1.5h3v3H0zM5 1.5h3v3H5zM10 1.5h3v3h-3zM15 1.5h3v3h-3z' style='fill:%239da7b7'/%3E%3C/svg%3E");
+ display: block;
+ text-align: center;
+ line-height: 0;
+ }
+}
+
+// Styles for placeholder elements that will eventually be replaced
+.ide-react-placeholder-chat {
+ background-color: var(--editor-toolbar-bg);
+ color: var(--neutral-20);
+ height: 100%;
+}
+
+.ide-react-placeholder-editor-sidebar {
+ height: 100%;
+ background-color: @file-tree-bg;
+ color: var(--neutral-20);
+}
+
+.ide-react-placeholder-symbol-palette {
+ height: 100%;
+ background-color: @symbol-palette-bg;
+ color: var(--neutral-20);
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 5076911b81..163e91b741 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1467,6 +1467,7 @@
"resending_confirmation_email": "Resending confirmation email",
"reset_password": "Reset Password",
"reset_your_password": "Reset your password",
+ "resize": "Resize",
"resolve": "Resolve",
"resolved_comments": "Resolved comments",
"restore": "Restore",
diff --git a/services/web/package.json b/services/web/package.json
index cedda9a331..1b56bab0bf 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -330,6 +330,7 @@
"react-refresh": "^0.14.0",
"react2angular": "^4.0.6",
"react2angular-shared-context": "^1.1.0",
+ "react-resizable-panels": "^0.0.55",
"requirejs": "^2.3.6",
"samlp": "^7.0.2",
"sandboxed-module": "overleaf/node-sandboxed-module#cafa2d60f17ce75cc023e6f296eb8de79d92d35d",