Просмотр исходного кода

Add review panel context providers and components (#19490)

* Tidy up review panel components

* Add ReviewPanel providers

* [web] new design for review panel track change (#19544)

* [web] new design for review panel track change

* fixed mini view

* mini icon style change

* fix icon size

* format date

* useRangesUserContext hook

* remove useRangesUserContext hook

* using full class names

* fix action icons hover

* change wording for tooltips

* added ReviewPanelChangeUser component

* Update header in new review panel

* Extract ReviewPanelTrackChangesMenuButton as a separate component

* Remove wrapper div

* Replace h2 with div for review panel label

* Rename ReviewPanelTools to ReviewPanelHeader

* Rename trackChangesExpanded -> trackChangesMenuExpanded

* Dont break memoisation of ReviewPanelTrackChangesMenuButton

* Fix the width of the track changes arrow icon

* Update how prop types are declared

* Remove new empty state from old review panel

* Add empty state to new review panel

* Add project members and owner to ChangesUsers context (#19624)

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>

* Redesign comment entry in review panel (#19678)

* Redesign comment entry in review panel

* ReviewPanelCommentOptions component

* remove unused prop

* Tidying

* Add conditional import

* Optional changeManager

* Add more split test compatibility

* More split test compatibility

* Fixes

* Improve overview scrolling

* Fix overview scrolling

* Fix & simplify track changes toggle

* Fix overview scrolling

* Fix current file container

* ExpandableContent component for messages in review panel (#19738)

* ExpandableContent component for messages in review panel

* remove isExpanded dependancy

* Delete comment option for new review panel (#19772)

* Delete comment option for new review panel

* dont show thread warning if there are no replies

* fix hasReplies issue

* Implement initial collapsing overview files

* Fix positioning of overview panel

* Small styling changes

* Add count of unresolved comments and tracked chanegs

* More style adjustments

* Move review-panel-overview styles into css file

* Remove unused var

---------

Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com>
Co-authored-by: David Powell <david.powell@overleaf.com>
Co-authored-by: David <33458145+davidmcpowell@users.noreply.github.com>
GitOrigin-RevId: e67463443d541f88445a86eed5e2b6ec6040f9c7
Alf Eaton 2 лет назад
Родитель
Сommit
2304536844
55 измененных файлов с 2972 добавлено и 213 удалено
  1. 10 0
      services/web/frontend/extracted-translations.json
  2. 1 1
      services/web/frontend/js/features/chat/components/chat-pane.tsx
  3. 1 1
      services/web/frontend/js/features/history/components/change-list/all-history-list.tsx
  4. 1 1
      services/web/frontend/js/features/history/components/change-list/labels-list.tsx
  5. 4 4
      services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
  6. 0 19
      services/web/frontend/js/features/ide-react/context/review-panel/review-panel-provider.tsx
  7. 61 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx
  8. 16 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-change-user.tsx
  9. 172 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx
  10. 30 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-comment-options.tsx
  11. 115 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx
  12. 22 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx
  13. 257 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx
  14. 32 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-delete-comment-modal.tsx
  15. 5 5
      services/web/frontend/js/features/review-panel-new/components/review-panel-empty-state.tsx
  16. 72 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx
  17. 43 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx
  18. 135 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx
  19. 94 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-overview-file.tsx
  20. 99 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx
  21. 87 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads.tsx
  22. 31 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx
  23. 34 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu-button.tsx
  24. 112 0
      services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx
  25. 74 0
      services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
  26. 56 0
      services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx
  27. 141 0
      services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx
  28. 22 0
      services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx
  29. 291 0
      services/web/frontend/js/features/review-panel-new/context/threads-context.tsx
  30. 43 0
      services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx
  31. 22 0
      services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts
  32. 15 0
      services/web/frontend/js/features/review-panel-new/utils/build-name.ts
  33. 15 0
      services/web/frontend/js/features/review-panel-new/utils/can-aggregate.ts
  34. 7 0
      services/web/frontend/js/features/review-panel-new/utils/is-focused.ts
  35. 7 0
      services/web/frontend/js/features/review-panel-new/utils/is-in-viewport.tsx
  36. 77 0
      services/web/frontend/js/features/review-panel-new/utils/position-items.ts
  37. 22 19
      services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
  38. 0 11
      services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx
  39. 1 19
      services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx
  40. 37 0
      services/web/frontend/js/features/source-editor/components/review-panel/review-panel-content.tsx
  41. 19 0
      services/web/frontend/js/features/source-editor/components/review-panel/review-panel-migration.tsx
  42. 7 60
      services/web/frontend/js/features/source-editor/components/review-panel/review-panel.tsx
  43. 13 0
      services/web/frontend/js/features/source-editor/context/review-panel/review-panel-context.tsx
  44. 7 6
      services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts
  45. 194 25
      services/web/frontend/js/features/source-editor/extensions/track-changes.ts
  46. 5 1
      services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts
  47. 27 3
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  48. 7 2
      services/web/frontend/js/shared/context/types/project-context.tsx
  49. 27 0
      services/web/frontend/js/utils/operations.ts
  50. 1 0
      services/web/frontend/stylesheets/app/editor.less
  51. 361 0
      services/web/frontend/stylesheets/app/editor/review-panel-new.less
  52. 28 32
      services/web/frontend/stylesheets/app/editor/review-panel.less
  53. 9 0
      services/web/locales/en.json
  54. 2 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx
  55. 1 1
      services/web/types/user.ts

+ 10 - 0
services/web/frontend/extracted-translations.json

@@ -20,6 +20,7 @@
   "accept": "",
   "accept_all": "",
   "accept_and_continue": "",
+  "accept_change": "",
   "accept_invitation": "",
   "accept_or_reject_each_changes_individually": "",
   "accept_terms_and_conditions": "",
@@ -281,6 +282,10 @@
   "delete_authentication_token": "",
   "delete_authentication_token_info": "",
   "delete_certificate": "",
+  "delete_comment": "",
+  "delete_comment_message": "",
+  "delete_comment_thread": "",
+  "delete_comment_thread_message": "",
   "delete_figure": "",
   "delete_projects": "",
   "delete_row_or_column": "",
@@ -822,6 +827,7 @@
   "more": "",
   "more_actions": "",
   "more_info": "",
+  "more_options": "",
   "more_options_for_border_settings_coming_soon": "",
   "my_library": "",
   "n_items": "",
@@ -1080,6 +1086,7 @@
   "regards": "",
   "reject": "",
   "reject_all": "",
+  "reject_change": "",
   "relink_your_account": "",
   "reload_editor": "",
   "remind_before_trial_ends": "",
@@ -1118,6 +1125,7 @@
   "resending_confirmation_email": "",
   "resize": "",
   "resolve": "",
+  "resolve_comment": "",
   "resolved_comments": "",
   "restore": "",
   "restore_file": "",
@@ -1242,6 +1250,7 @@
   "show_in_pdf": "",
   "show_less": "",
   "show_local_file_contents": "",
+  "show_more": "",
   "show_outline": "",
   "show_x_more_projects": "",
   "showing_1_result": "",
@@ -1479,6 +1488,7 @@
   "track_any_change_in_real_time": "",
   "track_changes": "",
   "track_changes_for_everyone": "",
+  "track_changes_for_guests": "",
   "track_changes_for_x": "",
   "track_changes_is_off": "",
   "track_changes_is_on": "",

+ 1 - 1
services/web/frontend/js/features/chat/components/chat-pane.tsx

@@ -67,7 +67,7 @@ const ChatPane = React.memo(function ChatPane() {
     throw error
   }
 
-  if (!user) {
+  if (!user?.id) {
     return null
   }
   if (!chatOpenedOnce) {

+ 1 - 1
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -225,7 +225,7 @@ function AllHistoryList() {
               showDivider={showDivider}
               setSelection={setSelection}
               selectionState={selectionState}
-              currentUserId={currentUserId}
+              currentUserId={currentUserId!}
               selectable={selectable}
               projectId={projectId}
               setActiveDropdownItem={setActiveDropdownItem}

+ 1 - 1
services/web/frontend/js/features/history/components/change-list/labels-list.tsx

@@ -33,7 +33,7 @@ function LabelsList() {
             key={version}
             labels={labels}
             version={version}
-            currentUserId={currentUserId}
+            currentUserId={currentUserId!}
             projectId={projectId}
             selectionState={selectionState}
             selectable={selectionState !== 'selected'}

+ 4 - 4
services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts

@@ -282,7 +282,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
           const tempUsers = {} as ReviewPanel.Value<'users'>
           // Always include ourself, since if we submit an op, we might need to display info
           // about it locally before it has been flushed through the server
-          if (user) {
+          if (user?.id) {
             tempUsers[user.id] = formatUser(user)
           }
 
@@ -724,7 +724,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
   )
 
   const applyTrackChangesStateToClient = useCallback(
-    (state: boolean | ReviewPanel.Value<'trackChangesState'>) => {
+    (state: boolean | Record<UserId | '__guests__', boolean>) => {
       if (typeof state === 'boolean') {
         setEveryoneTCState(state)
         setGuestsTCState(state)
@@ -1189,8 +1189,8 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
   // listen for events from the CodeMirror 6 track changes extension
   useEffect(() => {
     const toggleTrackChangesFromKbdShortcut = () => {
-      if (trackChangesVisible && trackChanges) {
-        const userId: UserId = user.id
+      const userId = user.id
+      if (trackChangesVisible && trackChanges && userId) {
         const state = trackChangesState[userId]
         if (state) {
           toggleTrackChangesForUser(!state.value, userId)

+ 0 - 19
services/web/frontend/js/features/ide-react/context/review-panel/review-panel-provider.tsx

@@ -1,19 +0,0 @@
-import useReviewPanelState from '@/features/ide-react/context/review-panel/hooks/use-review-panel-state'
-import {
-  ReviewPanelUpdaterFnsContext,
-  ReviewPanelValueContext,
-} from '@/features/source-editor/context/review-panel/review-panel-context'
-
-const ReviewPanelReactIdeProvider: React.FC = ({ children }) => {
-  const { values, updaterFns } = useReviewPanelState()
-
-  return (
-    <ReviewPanelValueContext.Provider value={values}>
-      <ReviewPanelUpdaterFnsContext.Provider value={updaterFns}>
-        {children}
-      </ReviewPanelUpdaterFnsContext.Provider>
-    </ReviewPanelValueContext.Provider>
-  )
-}
-
-export default ReviewPanelReactIdeProvider

+ 61 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx

@@ -0,0 +1,61 @@
+import { FC, FormEventHandler, useCallback, useState } from 'react'
+import {
+  useCodeMirrorStateContext,
+  useCodeMirrorViewContext,
+} from '@/features/source-editor/components/codemirror-editor'
+import { EditorSelection } from '@codemirror/state'
+import { PANEL_WIDTH } from './review-panel'
+import { useTranslation } from 'react-i18next'
+import { useThreadsActionsContext } from '../context/threads-context'
+
+export const ReviewPanelAddComment: FC = () => {
+  const { t } = useTranslation()
+  const view = useCodeMirrorViewContext()
+  // eslint-disable-next-line no-unused-vars
+  const _state = useCodeMirrorStateContext()
+  const { addComment } = useThreadsActionsContext()
+  const [error, setError] = useState<Error>()
+  const [showForm, setShowForm] = useState(false)
+
+  const handleSubmit = useCallback<FormEventHandler>(
+    event => {
+      event.preventDefault()
+
+      const { from, to } = view.state.selection.main
+      const content = view.state.sliceDoc(from, to)
+
+      const formData = new FormData(event.target as HTMLFormElement)
+      const message = formData.get('message') as string
+
+      addComment(from, content, message).catch(setError)
+
+      view.dispatch({
+        selection: EditorSelection.cursor(view.state.selection.main.anchor),
+      })
+    },
+    [addComment, view]
+  )
+
+  const handleElement = useCallback((element: HTMLElement | null) => {
+    if (element) {
+      element.dispatchEvent(new Event('review-panel:position'))
+    }
+  }, [])
+
+  if (!showForm) {
+    return <button onClick={() => setShowForm(true)}>{t('add_comment')}</button>
+  }
+
+  return (
+    <form
+      onSubmit={handleSubmit}
+      style={{ display: 'flex', flexDirection: 'column', width: PANEL_WIDTH }}
+      ref={handleElement}
+    >
+      {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
+      <textarea name="message" rows={3} autoFocus />
+      <button type="submit">{t('comment')}</button>
+      {error && <div>{error.message}</div>}
+    </form>
+  )
+}

+ 16 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-change-user.tsx

@@ -0,0 +1,16 @@
+import { memo, useMemo } from 'react'
+import { useChangesUsersContext } from '../context/changes-users-context'
+import { buildName } from '../utils/build-name'
+import { Change } from '../../../../../types/change'
+
+export const ReviewPanelChangeUser = memo<{ change: Change }>(({ change }) => {
+  const changesUsers = useChangesUsersContext()
+  const userId = change.metadata?.user_id
+  const userName = useMemo(
+    () => buildName(userId ? changesUsers?.get(userId) : undefined),
+    [changesUsers, userId]
+  )
+
+  return <span>{userName}</span>
+})
+ReviewPanelChangeUser.displayName = 'ReviewPanelChangeUser'

+ 172 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx

@@ -0,0 +1,172 @@
+import { memo } from 'react'
+import { useRangesActionsContext } from '../context/ranges-context'
+import {
+  Change,
+  DeleteOperation,
+  EditOperation,
+} from '../../../../../types/change'
+import { useTranslation } from 'react-i18next'
+import classnames from 'classnames'
+import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-editor'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+import { isFocused } from '../utils/is-focused'
+import { Button } from 'react-bootstrap'
+import Tooltip from '@/shared/components/tooltip'
+import MaterialIcon from '@/shared/components/material-icon'
+import { formatTimeBasedOnYear } from '@/features/utils/format-date'
+import { useChangesUsersContext } from '../context/changes-users-context'
+import { ReviewPanelChangeUser } from './review-panel-change-user'
+
+export const ReviewPanelChange = memo<{
+  change: Change<EditOperation>
+  aggregate?: Change<DeleteOperation>
+  top?: number
+}>(({ change, aggregate, top }) => {
+  const state = useCodeMirrorStateContext()
+  const { t } = useTranslation()
+  const { acceptChanges, rejectChanges } = useRangesActionsContext()
+  const permissions = usePermissionsContext()
+  const changesUsers = useChangesUsersContext()
+
+  if (!changesUsers) {
+    // if users are not loaded yet, do not show "Unknown" user
+    return null
+  }
+
+  const focused = isFocused(change.op, state.selection.main)
+
+  return (
+    <div
+      className={classnames('review-panel-entry', 'review-panel-entry-change', {
+        'review-panel-entry-focused': focused,
+        'review-panel-entry-insert': 'i' in change.op,
+        'review-panel-entry-delete': 'd' in change.op,
+        // TODO: aggregate
+      })}
+      data-top={top}
+      data-pos={change.op.p}
+      style={{
+        position: top === undefined ? 'relative' : 'absolute',
+        visibility: top === undefined ? 'visible' : 'hidden',
+        transition: 'top .3s, left .1s, right .1s',
+      }}
+    >
+      <div className="review-panel-entry-indicator">
+        <MaterialIcon type="edit" className="review-panel-entry-icon" />
+      </div>
+
+      <div className="review-panel-entry-content">
+        <div className="review-panel-entry-header">
+          <div>
+            <div className="review-panel-entry-user">
+              <ReviewPanelChangeUser change={change} />
+            </div>
+            <div className="review-panel-entry-time">
+              {formatTimeBasedOnYear(change.metadata?.ts)}
+            </div>
+          </div>
+          {permissions.write && (
+            <div className="review-panel-entry-actions">
+              <Tooltip
+                id="accept-change"
+                overlayProps={{ placement: 'bottom' }}
+                description={t('accept_change')}
+              >
+                <Button
+                  onClick={() =>
+                    aggregate
+                      ? acceptChanges(change.id, aggregate.id)
+                      : acceptChanges(change.id)
+                  }
+                  bsStyle={null}
+                >
+                  <MaterialIcon
+                    type="check"
+                    className="review-panel-entry-actions-icon"
+                    accessibilityLabel={t('accept_change')}
+                  />
+                </Button>
+              </Tooltip>
+
+              <Tooltip
+                id="reject-change"
+                description={t('reject_change')}
+                overlayProps={{ placement: 'bottom' }}
+              >
+                <Button
+                  bsStyle={null}
+                  onClick={() =>
+                    aggregate
+                      ? rejectChanges(change.id, aggregate.id)
+                      : rejectChanges(change.id)
+                  }
+                >
+                  <MaterialIcon
+                    className="review-panel-entry-actions-icon"
+                    accessibilityLabel={t('reject_change')}
+                    type="close"
+                  />
+                </Button>
+              </Tooltip>
+            </div>
+          )}
+        </div>
+
+        <div className="review-panel-change-body">
+          {'i' in change.op && (
+            <>
+              {aggregate ? (
+                <MaterialIcon
+                  className="review-panel-entry-icon review-panel-entry-icon-changed"
+                  type="edit"
+                />
+              ) : (
+                <MaterialIcon
+                  className="review-panel-entry-icon review-panel-entry-icon-accept"
+                  type="add_circle"
+                />
+              )}
+
+              {aggregate ? (
+                <span>
+                  {t('aggregate_changed')}:{' '}
+                  <del className="review-panel-content-highlight">
+                    {aggregate.op.d}
+                  </del>{' '}
+                  {t('aggregate_to')}{' '}
+                  <ins className="review-panel-content-highlight">
+                    {change.op.i}
+                  </ins>
+                </span>
+              ) : (
+                <span>
+                  {t('tracked_change_added')}:&nbsp;
+                  <ins className="review-panel-content-highlight">
+                    {change.op.i}
+                  </ins>
+                </span>
+              )}
+            </>
+          )}
+
+          {'d' in change.op && (
+            <>
+              <MaterialIcon
+                className="review-panel-entry-icon review-panel-entry-icon-reject"
+                type="delete"
+              />
+
+              <span>
+                {t('tracked_change_deleted')}:&nbsp;
+                <del className="review-panel-content-highlight">
+                  {change.op.d}
+                </del>
+              </span>
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+})
+ReviewPanelChange.displayName = 'ReviewPanelChange'

+ 30 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-comment-options.tsx

@@ -0,0 +1,30 @@
+import ControlledDropdown from '@/shared/components/controlled-dropdown'
+import MaterialIcon from '@/shared/components/material-icon'
+import { FC, memo } from 'react'
+import { Dropdown, MenuItem } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+
+const ReviewPanelCommentOptions: FC<{
+  onEdit: () => void
+  onDelete: () => void
+}> = ({ onEdit, onDelete }) => {
+  const { t } = useTranslation()
+
+  return (
+    <ControlledDropdown id="review-panel-comment-options" pullRight>
+      <Dropdown.Toggle noCaret bsSize="small" bsStyle={null}>
+        <MaterialIcon
+          type="more_vert"
+          className="review-panel-entry-actions-icon"
+          accessibilityLabel={t('more_options')}
+        />
+      </Dropdown.Toggle>
+      <Dropdown.Menu>
+        <MenuItem onClick={onEdit}>{t('edit')}</MenuItem>
+        <MenuItem onClick={onDelete}>{t('delete')}</MenuItem>
+      </Dropdown.Menu>
+    </ControlledDropdown>
+  )
+}
+
+export default memo(ReviewPanelCommentOptions)

+ 115 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx

@@ -0,0 +1,115 @@
+import { memo, useCallback, useState } from 'react'
+import { Change, CommentOperation } from '../../../../../types/change'
+import { ReviewPanelMessage } from './review-panel-message'
+import { useTranslation } from 'react-i18next'
+import {
+  useThreadsActionsContext,
+  useThreadsContext,
+} from '../context/threads-context'
+import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-editor'
+import classnames from 'classnames'
+import { isFocused } from '../utils/is-focused'
+import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
+import MaterialIcon from '@/shared/components/material-icon'
+
+export const ReviewPanelComment = memo<{
+  comment: Change<CommentOperation>
+  top?: number
+}>(({ comment, top }) => {
+  const { t } = useTranslation()
+  const [submitting, setSubmitting] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [content, setContent] = useState('')
+  const threads = useThreadsContext()
+  const { resolveThread, addMessage } = useThreadsActionsContext()
+  const state = useCodeMirrorStateContext()
+
+  const handleSubmitReply = useCallback(() => {
+    setSubmitting(true)
+    addMessage(comment.op.t, content)
+      .then(() => {
+        setContent('')
+      })
+      .catch(error => {
+        setError(error)
+      })
+      .finally(() => {
+        setSubmitting(false)
+      })
+  }, [addMessage, comment.op.t, content])
+
+  const handleCommentReplyKeyPress = (
+    e: React.KeyboardEvent<HTMLTextAreaElement>
+  ) => {
+    if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
+      e.preventDefault()
+      handleSubmitReply()
+    }
+  }
+
+  const thread = threads?.[comment.op.t]
+  if (!thread || thread.resolved) {
+    return null
+  }
+
+  const focused = isFocused(comment.op, state.selection.main)
+
+  return (
+    <div
+      className={classnames(
+        'review-panel-entry',
+        'review-panel-entry-comment',
+        {
+          'review-panel-entry-loaded': !!threads?.[comment.op.t],
+          'review-panel-entry-focused': focused,
+        }
+      )}
+      data-top={top}
+      data-pos={comment.op.p}
+      style={{
+        position: top === undefined ? 'relative' : 'absolute',
+        visibility: top === undefined ? 'visible' : 'hidden',
+        transition: 'top .3s, left .1s, right .1s',
+      }}
+    >
+      <div className="review-panel-entry-indicator">
+        <MaterialIcon type="edit" className="review-panel-entry-icon" />
+      </div>
+
+      <div className="review-panel-entry-content">
+        {thread.messages.map((message, i) => {
+          const isReply = i !== 0
+
+          return (
+            <div key={message.id} className="review-panel-comment-wrapper">
+              {isReply && (
+                <div className="review-panel-comment-reply-divider" />
+              )}
+
+              <ReviewPanelMessage
+                message={message}
+                threadId={comment.op.t}
+                isReply={isReply}
+                hasReplies={!isReply && thread.messages.length > 1}
+                onResolve={() => resolveThread(comment.op.t)}
+              />
+            </div>
+          )
+        })}
+
+        <AutoExpandingTextArea
+          name="content"
+          className="review-panel-comment-input"
+          onChange={e => setContent(e.target.value)}
+          onKeyDown={handleCommentReplyKeyPress}
+          placeholder={t('reply')}
+          value={content}
+          disabled={submitting}
+        />
+
+        {error && <div>{error.message}</div>}
+      </div>
+    </div>
+  )
+})
+ReviewPanelComment.displayName = 'ReviewPanelComment'

+ 22 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx

@@ -0,0 +1,22 @@
+import ReactDOM from 'react-dom'
+import { useCodeMirrorViewContext } from '../../source-editor/components/codemirror-editor'
+import { memo } from 'react'
+import ReviewPanel from './review-panel'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import { useRangesContext } from '../context/ranges-context'
+
+function ReviewPanelContainer() {
+  const view = useCodeMirrorViewContext()
+  const ranges = useRangesContext()
+  const { reviewPanelOpen } = useLayoutContext()
+
+  const mini = !reviewPanelOpen && !!ranges?.total
+
+  if (!view || (!reviewPanelOpen && !mini)) {
+    return null
+  }
+
+  return ReactDOM.createPortal(<ReviewPanel mini={mini} />, view.scrollDOM)
+}
+
+export default memo(ReviewPanelContainer)

+ 257 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx

@@ -0,0 +1,257 @@
+import {
+  FC,
+  memo,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import { ReviewPanelAddComment } from './review-panel-add-comment'
+import { ReviewPanelChange } from './review-panel-change'
+import { ReviewPanelComment } from './review-panel-comment'
+import {
+  Change,
+  CommentOperation,
+  DeleteOperation,
+  EditOperation,
+} from '../../../../../types/change'
+import {
+  editorOverflowPadding,
+  editorVerticalTopPadding,
+} from '@/features/source-editor/extensions/vertical-overflow'
+import {
+  useCodeMirrorStateContext,
+  useCodeMirrorViewContext,
+} from '@/features/source-editor/components/codemirror-editor'
+import { useRangesContext } from '../context/ranges-context'
+import { useThreadsContext } from '../context/threads-context'
+import { isDeleteChange, isInsertChange } from '@/utils/operations'
+import Icon from '@/shared/components/icon'
+import { positionItems } from '../utils/position-items'
+import { canAggregate } from '../utils/can-aggregate'
+import { isInViewport } from '../utils/is-in-viewport'
+import ReviewPanelEmptyState from './review-panel-empty-state'
+
+type Positions = Map<string, number>
+type Aggregates = Map<string, Change<DeleteOperation>>
+
+type RangesWithPositions = {
+  changes: Change<EditOperation>[]
+  comments: Change<CommentOperation>[]
+  positions: Positions
+  aggregates: Aggregates
+}
+
+const ReviewPanelCurrentFile: FC = () => {
+  const view = useCodeMirrorViewContext()
+  const ranges = useRangesContext()
+  const threads = useThreadsContext()
+  const state = useCodeMirrorStateContext()
+
+  const [rangesWithPositions, setRangesWithPositions] =
+    useState<RangesWithPositions>()
+
+  const contentRect = view.contentDOM.getBoundingClientRect()
+
+  const editorPaddingTop = editorVerticalTopPadding(view)
+  const topDiff = contentRect.top - editorPaddingTop
+  const docLength = state.doc.length
+
+  const screenPosition = useCallback(
+    (change: Change): number | undefined => {
+      const pos = Math.min(change.op.p, docLength)
+      const coords = view.coordsAtPos(pos)
+
+      return coords ? Math.round(coords.top - topDiff) : undefined
+    },
+    [docLength, topDiff, view]
+  )
+
+  const selectionCoords = useMemo(
+    () =>
+      state.selection.main.empty
+        ? null
+        : view.coordsAtPos(state.selection.main.head),
+    [view, state]
+  )
+
+  const containerRef = useRef<HTMLDivElement | null>(null)
+  const ignoreNextUpdateRef = useRef(false)
+  const previousFocusedItem = useRef(0)
+
+  const updatePositions = useCallback(() => {
+    if (ignoreNextUpdateRef.current) {
+      ignoreNextUpdateRef.current = false
+      return
+    }
+
+    if (containerRef.current) {
+      const extents = positionItems(
+        containerRef.current,
+        view.scrollDOM as HTMLDivElement,
+        previousFocusedItem.current
+      )
+
+      if (extents) {
+        previousFocusedItem.current = extents.focusedItemIndex
+
+        window.setTimeout(() => {
+          const top = extents.min < 0 ? -extents.min : 0
+          const bottom =
+            extents.max > contentRect.bottom
+              ? extents.max - contentRect.bottom
+              : 0
+
+          const currentPadding = editorOverflowPadding(view)
+
+          if (
+            currentPadding?.top !== top ||
+            currentPadding?.bottom !== bottom
+          ) {
+            // ignoreNextUpdateRef.current = true
+            // view.dispatch(setVerticalOverflow({ top, bottom }))
+          }
+        })
+      }
+    }
+  }, [contentRect.bottom, view])
+
+  useEffect(() => {
+    const timer = window.setTimeout(() => {
+      updatePositions()
+    }, 100)
+
+    return () => {
+      window.clearTimeout(timer)
+    }
+  }, [state, updatePositions, view.viewport.from, view.viewport.to])
+
+  useEffect(() => {
+    const element = containerRef.current
+    if (element) {
+      element.addEventListener('review-panel:position', updatePositions)
+      // view.scrollDOM.addEventListener('scroll', positionListener)
+      return () => {
+        element.removeEventListener('review-panel:position', updatePositions)
+        // view.scrollDOM.removeEventListener('scroll', positionListener)
+      }
+    }
+  }, [view, updatePositions])
+
+  useEffect(() => {
+    if (ranges) {
+      view.requestMeasure({
+        key: 'review-panel-position',
+        read(view): RangesWithPositions {
+          const isVisible = isInViewport(view)
+
+          const output: RangesWithPositions = {
+            positions: new Map(),
+            aggregates: new Map(),
+            changes: [],
+            comments: [],
+          }
+
+          let precedingChange: Change<EditOperation> | null = null
+
+          for (const change of ranges.changes) {
+            if (isVisible(change)) {
+              if (
+                precedingChange &&
+                isInsertChange(precedingChange) &&
+                isDeleteChange(change) &&
+                canAggregate(change, precedingChange)
+              ) {
+                output.aggregates.set(precedingChange.id, change)
+              } else {
+                output.changes.push(change)
+
+                const position = screenPosition(change)
+                if (position) {
+                  output.positions.set(change.id, position)
+                }
+              }
+            }
+
+            precedingChange = change
+          }
+
+          if (threads) {
+            for (const comment of ranges.comments) {
+              if (isVisible(comment)) {
+                output.comments.push(comment)
+                if (!threads[comment.op.t]?.resolved) {
+                  const position = screenPosition(comment)
+                  if (position) {
+                    output.positions.set(comment.id, position)
+                  }
+                }
+              }
+            }
+          }
+
+          return output
+        },
+        write(positionedRanges) {
+          setRangesWithPositions(positionedRanges)
+          window.setTimeout(() => {
+            containerRef.current?.dispatchEvent(
+              new Event('review-panel:position')
+            )
+          })
+        },
+      })
+    }
+  }, [view, threads, ranges, screenPosition, containerRef])
+
+  if (!rangesWithPositions) {
+    return null
+  }
+
+  const showEmptyState =
+    threads &&
+    rangesWithPositions.changes.length === 0 &&
+    rangesWithPositions.comments.length === 0
+
+  return (
+    <div ref={containerRef}>
+      {selectionCoords && (
+        <div
+          className="review-panel-entry"
+          style={{ position: 'absolute' }}
+          data-top={selectionCoords.top + view.scrollDOM.scrollTop - 70}
+          data-pos={state.selection.main.head}
+        >
+          <div className="review-panel-entry-indicator">
+            <Icon type="pencil" fw />
+          </div>
+          <div className="review-panel-entry-content">
+            <ReviewPanelAddComment />
+          </div>
+        </div>
+      )}
+
+      {showEmptyState && <ReviewPanelEmptyState />}
+
+      {rangesWithPositions.changes.map(change => (
+        <ReviewPanelChange
+          key={change.id}
+          change={change}
+          top={rangesWithPositions.positions.get(change.id)}
+          aggregate={rangesWithPositions.aggregates.get(change.id)}
+        />
+      ))}
+
+      {rangesWithPositions.comments.map(comment => (
+        <ReviewPanelComment
+          key={comment.id}
+          comment={comment}
+          top={rangesWithPositions.positions.get(comment.id)}
+        />
+      ))}
+    </div>
+  )
+}
+
+export default memo(ReviewPanelCurrentFile)

+ 32 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-delete-comment-modal.tsx

@@ -0,0 +1,32 @@
+import AccessibleModal from '@/shared/components/accessible-modal'
+import { FC, memo } from 'react'
+import { Button, Modal } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+
+const ReviewPanelDeleteCommentModal: FC<{
+  onHide: () => void
+  onDelete: () => void
+  title: string
+  message: string
+}> = ({ onHide, onDelete, title, message }) => {
+  const { t } = useTranslation()
+
+  return (
+    <AccessibleModal show onHide={onHide}>
+      <Modal.Header>
+        <Modal.Title>{title}</Modal.Title>
+      </Modal.Header>
+      <Modal.Body>{message}</Modal.Body>
+      <Modal.Footer>
+        <Button bsStyle={null} className="btn-secondary" onClick={onHide}>
+          {t('cancel')}
+        </Button>
+        <Button bsStyle="danger" onClick={onDelete}>
+          {t('delete')}
+        </Button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}
+
+export default memo(ReviewPanelDeleteCommentModal)

+ 5 - 5
services/web/frontend/js/features/source-editor/components/review-panel/empty-state.tsx → services/web/frontend/js/features/review-panel-new/components/review-panel-empty-state.tsx

@@ -1,13 +1,13 @@
 import MaterialIcon from '@/shared/components/material-icon'
 import { useTranslation } from 'react-i18next'
 
-function EmptyState() {
+function ReviewPanelEmptyState() {
   const { t } = useTranslation()
 
   return (
-    <div className="rp-empty-state">
-      <div className="rp-empty-state-inner">
-        <div className="rp-empty-state-comment-icon">
+    <div className="review-panel-empty-state">
+      <div className="review-panel-empty-state-inner">
+        <div className="review-panel-empty-state-comment-icon">
           <MaterialIcon type="question_answer" />
         </div>
         <p>
@@ -19,4 +19,4 @@ function EmptyState() {
   )
 }
 
-export default EmptyState
+export default ReviewPanelEmptyState

+ 72 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx

@@ -0,0 +1,72 @@
+import { FC, useCallback, useEffect, useRef, useState } from 'react'
+import classNames from 'classnames'
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+
+export const ExpandableContent: FC<{ className?: string }> = ({
+  children,
+  className,
+}) => {
+  const { t } = useTranslation()
+  const contentRef = useRef<HTMLDivElement>(null)
+  const [isExpanded, setIsExpanded] = useState(false)
+  const [isOverflowing, setIsOverflowing] = useState(false)
+
+  useEffect(() => {
+    if (contentRef.current) {
+      setIsOverflowing(
+        contentRef.current.scrollHeight > contentRef.current.clientHeight
+      )
+    }
+  }, [])
+
+  const handleShowMore = useCallback(() => {
+    setIsExpanded(true)
+    contentRef.current?.dispatchEvent(
+      new CustomEvent('review-panel:position', { bubbles: true })
+    )
+  }, [])
+
+  const handleShowLess = useCallback(() => {
+    setIsExpanded(false)
+    contentRef.current?.dispatchEvent(
+      new CustomEvent('review-panel:position', { bubbles: true })
+    )
+  }, [])
+
+  return (
+    <div>
+      <div
+        ref={contentRef}
+        className={classNames(
+          'review-panel-content-expandable',
+          {
+            'review-panel-content-expanded': isExpanded,
+          },
+          className
+        )}
+      >
+        {children}
+      </div>
+      {isExpanded ? (
+        <Button
+          bsStyle="link"
+          className="btn-inline-link"
+          onClick={handleShowLess}
+        >
+          {t('show_less')}
+        </Button>
+      ) : (
+        isOverflowing && (
+          <Button
+            bsStyle="link"
+            className="btn-inline-link"
+            onClick={handleShowMore}
+          >
+            {t('show_more')}
+          </Button>
+        )
+      )}
+    </div>
+  )
+}

+ 43 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx

@@ -0,0 +1,43 @@
+import { FC, memo, useState } from 'react'
+import { ReviewPanelResolvedThreads } from './review-panel-resolved-threads'
+import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
+import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
+import { Button } from 'react-bootstrap'
+import MaterialIcon from '@/shared/components/material-icon'
+import { useLayoutContext } from '@/shared/context/layout-context'
+
+const ReviewPanelHeader: FC<{
+  top: number
+  width: number
+}> = ({ top, width }) => {
+  const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
+    useState(false)
+  const { setReviewPanelOpen } = useLayoutContext()
+
+  return (
+    <div className="review-panel-header" style={{ top, width }}>
+      <div className="review-panel-heading">
+        <div className="review-panel-label">Review</div>
+        <Button
+          bsStyle={null}
+          className="review-panel-close-button"
+          type="button"
+          onClick={() => setReviewPanelOpen(false)}
+        >
+          <MaterialIcon type="close" />
+        </Button>
+      </div>
+      <div className="review-panel-tools">
+        <ReviewPanelResolvedThreads />
+        <ReviewPanelTrackChangesMenuButton
+          menuExpanded={trackChangesMenuExpanded}
+          setMenuExpanded={setTrackChangesMenuExpanded}
+        />
+      </div>
+
+      {trackChangesMenuExpanded && <ReviewPanelTrackChangesMenu />}
+    </div>
+  )
+}
+
+export default memo(ReviewPanelHeader)

+ 135 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx

@@ -0,0 +1,135 @@
+import { FC, useCallback, useState } from 'react'
+import {
+  ReviewPanelCommentThreadMessage,
+  ThreadId,
+} from '../../../../../types/review-panel/review-panel'
+import { useTranslation } from 'react-i18next'
+import { useThreadsActionsContext } from '../context/threads-context'
+import { formatTimeBasedOnYear } from '@/features/utils/format-date'
+import Tooltip from '@/shared/components/tooltip'
+import { Button } from 'react-bootstrap'
+import MaterialIcon from '@/shared/components/material-icon'
+import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
+import { buildName } from '../utils/build-name'
+import ReviewPanelCommentOptions from './review-panel-comment-options'
+import { ExpandableContent } from './review-panel-expandable-content'
+import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
+
+export const ReviewPanelMessage: FC<{
+  message: ReviewPanelCommentThreadMessage
+  threadId: ThreadId
+  hasReplies: boolean
+  isReply: boolean
+  onResolve: () => void
+}> = ({ message, threadId, isReply, hasReplies, onResolve }) => {
+  const { t } = useTranslation()
+  const [editing, setEditing] = useState(false)
+  const [deleting, setDeleting] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [content, setContent] = useState(message.content)
+  const { editMessage, deleteMessage } = useThreadsActionsContext()
+
+  const handleEditOption = useCallback(() => setEditing(true), [])
+  const showDeleteModal = useCallback(() => setDeleting(true), [])
+  const hideDeleteModal = useCallback(() => setDeleting(false), [])
+
+  const handleSubmit = useCallback(async () => {
+    await editMessage(threadId, message.id, content)
+      .catch(error => {
+        setError(error)
+      })
+      .finally(() => {
+        setEditing(false)
+      })
+  }, [content, editMessage, message.id, threadId])
+
+  const handleDelete = useCallback(async () => {
+    await deleteMessage(threadId, message.id)
+      .catch(error => {
+        setError(error)
+      })
+      .finally(() => {
+        setDeleting(false)
+      })
+  }, [deleteMessage, message.id, threadId])
+
+  if (editing) {
+    return (
+      <div>
+        <AutoExpandingTextArea
+          className="review-panel-comment-input"
+          onBlur={handleSubmit}
+          onChange={e => setContent(e.target.value)}
+          onKeyDown={e => {
+            if (
+              e.key === 'Enter' &&
+              !e.shiftKey &&
+              !e.ctrlKey &&
+              !e.metaKey &&
+              content
+            ) {
+              e.preventDefault()
+              handleSubmit()
+            }
+          }}
+          value={content}
+          autoFocus // eslint-disable-line jsx-a11y/no-autofocus
+        />
+        {error && <div>{error.message}</div>}
+      </div>
+    )
+  }
+
+  return (
+    <div className="review-panel-comment">
+      <div className="review-panel-entry-header">
+        <div>
+          <div className="review-panel-entry-user">
+            {buildName(message.user)}
+          </div>
+          <div className="review-panel-entry-time">
+            {formatTimeBasedOnYear(message.timestamp)}
+          </div>
+        </div>
+
+        <div className="review-panel-entry-actions">
+          {!isReply && (
+            <Tooltip
+              id="resolve-thread"
+              overlayProps={{ placement: 'bottom' }}
+              description={t('resolve_comment')}
+            >
+              <Button onClick={onResolve} bsStyle={null}>
+                <MaterialIcon
+                  type="check"
+                  className="review-panel-entry-actions-icon"
+                  accessibilityLabel={t('resolve_comment')}
+                />
+              </Button>
+            </Tooltip>
+          )}
+
+          <ReviewPanelCommentOptions
+            onEdit={handleEditOption}
+            onDelete={showDeleteModal}
+          />
+        </div>
+      </div>
+      <ExpandableContent className="review-panel-comment-body">
+        {message.content}
+      </ExpandableContent>
+      {deleting && (
+        <ReviewPanelDeleteCommentModal
+          onHide={hideDeleteModal}
+          onDelete={handleDelete}
+          title={hasReplies ? t('delete_comment_thread') : t('delete_comment')}
+          message={
+            hasReplies
+              ? t('delete_comment_thread_message')
+              : t('delete_comment_message')
+          }
+        />
+      )}
+    </div>
+  )
+}

+ 94 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-overview-file.tsx

@@ -0,0 +1,94 @@
+import { FC, useMemo } from 'react'
+import { MainDocument } from '../../../../../types/project-settings'
+import { Ranges } from '../context/ranges-context'
+import { ReviewPanelComment } from './review-panel-comment'
+import { ReviewPanelChange } from './review-panel-change'
+import { isDeleteChange, isInsertChange } from '@/utils/operations'
+import {
+  Change,
+  DeleteOperation,
+  EditOperation,
+} from '../../../../../types/change'
+import { canAggregate } from '../utils/can-aggregate'
+
+import { Button } from 'react-bootstrap'
+import MaterialIcon from '@/shared/components/material-icon'
+import useOverviewFileCollapsed from '../hooks/use-overview-file-collapsed'
+import { useThreadsContext } from '../context/threads-context'
+
+export const ReviewPanelOverviewFile: FC<{
+  doc: MainDocument
+  ranges: Ranges
+}> = ({ doc, ranges }) => {
+  const { collapsed, toggleCollapsed } = useOverviewFileCollapsed(doc.doc.id)
+  const threads = useThreadsContext()
+
+  const { aggregates, changes } = useMemo(() => {
+    const changes: Change<EditOperation>[] = []
+    const aggregates: Map<string, Change<DeleteOperation>> = new Map()
+
+    let precedingChange: Change<EditOperation> | null = null
+    for (const change of ranges.changes) {
+      if (
+        precedingChange &&
+        isInsertChange(precedingChange) &&
+        isDeleteChange(change) &&
+        canAggregate(change, precedingChange)
+      ) {
+        aggregates.set(precedingChange.id, change)
+      } else {
+        changes.push(change)
+      }
+      precedingChange = change
+    }
+
+    return { aggregates, changes }
+  }, [ranges])
+
+  const unresolvedComments = useMemo(() => {
+    return ranges.comments.filter(comment => {
+      const thread = threads?.[comment.op.t]
+      return thread && !thread.resolved
+    })
+  }, [ranges.comments, threads])
+
+  const numEntries = changes.length + unresolvedComments.length
+
+  if (numEntries === 0) {
+    return null
+  }
+
+  return (
+    <div>
+      <Button
+        bsClass="review-panel-overview-file-header"
+        bsStyle={null}
+        onClick={toggleCollapsed}
+      >
+        <MaterialIcon
+          type={collapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'}
+        />
+        {doc.doc.name}
+        <div className="review-panel-overview-file-entry-count">
+          {numEntries}
+        </div>
+      </Button>
+
+      {!collapsed && (
+        <div className="review-panel-overview-file-entries">
+          {changes.map(change => (
+            <ReviewPanelChange
+              key={change.id}
+              change={change}
+              aggregate={aggregates.get(change.id)}
+            />
+          ))}
+
+          {unresolvedComments.map(comment => (
+            <ReviewPanelComment key={comment.id} comment={comment} />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}

+ 99 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx

@@ -0,0 +1,99 @@
+import { FC, useEffect, useMemo, useState } from 'react'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { Ranges, useRangesContext } from '../context/ranges-context'
+import { getJSON } from '@/infrastructure/fetch-json'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useTranslation } from 'react-i18next'
+import { ReviewPanelOverviewFile } from './review-panel-overview-file'
+import ReviewPanelEmptyState from './review-panel-empty-state'
+
+export const ReviewPanelOverview: FC = () => {
+  const { t } = useTranslation()
+  const { _id: projectId } = useProjectContext()
+  const { docs } = useFileTreeData()
+  const [error, setError] = useState<Error>()
+  const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
+  const docRanges = useRangesContext()
+
+  useEffect(() => {
+    getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
+      .then(data =>
+        setProjectRanges(
+          new Map(
+            data.map(item => [
+              item.id,
+              {
+                docId: item.id,
+                changes: item.ranges.changes ?? [],
+                comments: item.ranges.comments ?? [],
+                total: 0, // TODO
+              },
+            ])
+          )
+        )
+      )
+      .catch(error => setError(error))
+  }, [projectId])
+
+  const rangesForDocs = useMemo(() => {
+    if (docs && docRanges && projectRanges) {
+      const rangesForDocs = new Map<string, Ranges>()
+
+      for (const doc of docs) {
+        const ranges =
+          doc.doc.id === docRanges.docId
+            ? docRanges
+            : projectRanges.get(doc.doc.id)
+
+        if (ranges) {
+          rangesForDocs.set(doc.doc.id, ranges)
+        }
+      }
+
+      return rangesForDocs
+    }
+  }, [docRanges, docs, projectRanges])
+
+  const showEmptyState = useMemo((): boolean => {
+    if (!rangesForDocs) {
+      // data isn't loaded yet
+      return false
+    }
+
+    for (const ranges of rangesForDocs.values()) {
+      if (ranges.changes.length > 0 || ranges.comments.length > 0) {
+        return false
+      }
+    }
+
+    return true
+  }, [rangesForDocs])
+
+  return (
+    <div className="review-panel-overview">
+      {error && <div>{t('something_went_wrong')}</div>}
+
+      {showEmptyState && <ReviewPanelEmptyState />}
+
+      {docs && rangesForDocs && (
+        <div>
+          {docs.map(doc => {
+            const ranges = rangesForDocs.get(doc.doc.id)
+            return (
+              ranges && (
+                <>
+                  <ReviewPanelOverviewFile
+                    key={doc.doc.id}
+                    doc={doc}
+                    ranges={ranges}
+                  />
+                  <div className="review-panel-overfile-divider" />
+                </>
+              )
+            )
+          })}
+        </div>
+      )}
+    </div>
+  )
+}

+ 87 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads.tsx

@@ -0,0 +1,87 @@
+import React, { FC, useMemo, useRef, useState } from 'react'
+import { Overlay, Popover } from 'react-bootstrap'
+import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
+import {
+  useThreadsActionsContext,
+  useThreadsContext,
+} from '../context/threads-context'
+import { useTranslation } from 'react-i18next'
+import { ThreadId } from '../../../../../types/review-panel/review-panel'
+import Icon from '@/shared/components/icon'
+
+export const ReviewPanelResolvedThreads: FC = () => {
+  const { t } = useTranslation()
+  const view = useCodeMirrorViewContext()
+  const threads = useThreadsContext()
+  const { reopenThread, deleteThread } = useThreadsActionsContext()
+
+  const [expanded, setExpanded] = useState(false)
+
+  const buttonRef = useRef<HTMLButtonElement>(null)
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const resolvedThreads = useMemo(() => {
+    if (!threads) {
+      return []
+    }
+
+    const resolvedThreads = []
+    for (const [id, thread] of Object.entries(threads)) {
+      if (thread.resolved) {
+        resolvedThreads.push({ ...thread, id })
+      }
+    }
+    return resolvedThreads
+  }, [threads])
+
+  return (
+    <>
+      <button
+        className="resolved-comments-toggle"
+        ref={buttonRef}
+        onClick={() => setExpanded(true)}
+      >
+        <Icon type="inbox" fw />
+      </button>
+
+      {expanded && (
+        <Overlay
+          show
+          onHide={() => setExpanded(false)}
+          animation={false}
+          container={view.scrollDOM}
+          containerPadding={0}
+          placement="bottom"
+          rootClose
+          target={buttonRef.current ?? undefined}
+        >
+          <Popover id="popover-resolved-threads">
+            {resolvedThreads.length ? (
+              <div className="resolved-comments">
+                {resolvedThreads.map(thread => (
+                  <div key={thread.id}>
+                    <div>{thread.resolved_at}</div>
+                    <div>
+                      <button
+                        onClick={() => reopenThread(thread.id as ThreadId)}
+                      >
+                        {t('reopen')}
+                      </button>
+                      <button
+                        onClick={() => deleteThread(thread.id as ThreadId)}
+                      >
+                        {t('delete')}
+                      </button>
+                    </div>
+                  </div>
+                ))}
+              </div>
+            ) : (
+              <div>{t('no_resolved_threads')}</div>
+            )}
+          </Popover>
+        </Overlay>
+      )}
+    </>
+  )
+}

+ 31 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx

@@ -0,0 +1,31 @@
+import { Dispatch, FC, memo, SetStateAction } from 'react'
+import classnames from 'classnames'
+import { SubView } from '../components/review-panel'
+
+const ReviewPanelTabs: FC<{
+  subView: SubView
+  setSubView: Dispatch<SetStateAction<SubView>>
+}> = ({ subView, setSubView }) => {
+  return (
+    <>
+      <button
+        className={classnames('rp-nav-item', {
+          'rp-nav-item-active': subView === 'cur_file',
+        })}
+        onClick={() => setSubView('cur_file')}
+      >
+        Current file
+      </button>
+      <button
+        className={classnames('rp-nav-item', {
+          'rp-nav-item-active': subView === 'overview',
+        })}
+        onClick={() => setSubView('overview')}
+      >
+        Overview
+      </button>
+    </>
+  )
+}
+
+export default memo(ReviewPanelTabs)

+ 34 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu-button.tsx

@@ -0,0 +1,34 @@
+import { FC, memo } from 'react'
+import { Trans } from 'react-i18next'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import Icon from '@/shared/components/icon'
+
+const ReviewPanelTrackChangesMenuButton: FC<{
+  menuExpanded: boolean
+  setMenuExpanded: React.Dispatch<React.SetStateAction<boolean>>
+}> = ({ menuExpanded, setMenuExpanded }) => {
+  const { wantTrackChanges } = useEditorManagerContext()
+
+  return (
+    <button
+      className="track-changes-menu-button"
+      onClick={() => setMenuExpanded(value => !value)}
+    >
+      {wantTrackChanges && <div className="track-changes-indicator-circle" />}
+      {wantTrackChanges ? (
+        <Trans
+          i18nKey="track_changes_is_on"
+          components={{ strong: <strong /> }}
+        />
+      ) : (
+        <Trans
+          i18nKey="track_changes_is_off"
+          components={{ strong: <strong /> }}
+        />
+      )}
+      <Icon type={menuExpanded ? 'angle-down' : 'angle-right'} />
+    </button>
+  )
+}
+
+export default memo(ReviewPanelTrackChangesMenuButton)

+ 112 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx

@@ -0,0 +1,112 @@
+import { FC, useCallback } from 'react'
+import TrackChangesToggle from '@/features/source-editor/components/review-panel/toolbar/track-changes-toggle'
+import { useProjectContext } from '@/shared/context/project-context'
+import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+import { useTranslation } from 'react-i18next'
+import { useTrackChangesStateContext } from '../context/track-changes-state-context'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { useChangesUsersContext } from '../context/changes-users-context'
+import { UserId } from '../../../../../types/user'
+import { buildName } from '../utils/build-name'
+
+export const ReviewPanelTrackChangesMenu: FC = () => {
+  const { t } = useTranslation()
+  const permissions = usePermissionsContext()
+  const project = useProjectContext()
+  const trackChanges = useTrackChangesStateContext()
+  const changesUsers = useChangesUsersContext()
+
+  const saveTrackChanges = useCallback(
+    body => {
+      postJSON(`/project/${project._id}/track_changes`, {
+        body,
+      })
+    },
+    [project._id]
+  )
+
+  if (trackChanges === undefined || !changesUsers) {
+    return null
+  }
+
+  const trackChangesIsObject = trackChanges !== true && trackChanges !== false
+  const onForEveryone = trackChanges === true
+  const onForGuests =
+    onForEveryone || (trackChangesIsObject && trackChanges.__guests__ === true)
+
+  const trackChangesValues: Record<UserId, boolean | undefined> = {}
+  if (trackChangesIsObject) {
+    for (const key of Object.keys(trackChanges)) {
+      if (key !== '__guests__') {
+        trackChangesValues[key as UserId] = trackChanges[key as UserId]
+      }
+    }
+  }
+
+  const canToggle = project.features.trackChanges && permissions.write
+
+  return (
+    <div className="rp-tc-state">
+      <div className="rp-tc-state-item">
+        <span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
+
+        <TrackChangesToggle
+          id="track-changes-everyone"
+          description={t('track_changes_for_everyone')}
+          handleToggle={() =>
+            saveTrackChanges(onForEveryone ? { on_for: {} } : { on: true })
+          }
+          value={onForEveryone}
+          disabled={!canToggle}
+        />
+      </div>
+
+      {[project.owner, ...project.members].map(member => {
+        const user = changesUsers.get(member._id) ?? member
+        const name = buildName(user)
+
+        const value =
+          trackChanges === true || trackChangesValues[member._id] === true
+
+        return (
+          <div key={member._id} className="rp-tc-state-item">
+            <span className="rp-tc-state-item-name">{name}</span>
+
+            <TrackChangesToggle
+              id={`track-changes-${member._id}`}
+              description={t('track_changes_for_x', { name })}
+              handleToggle={() => {
+                saveTrackChanges({
+                  on_for: {
+                    ...trackChangesValues,
+                    [member._id]: !value,
+                  },
+                  on_for_guests: onForGuests,
+                })
+              }}
+              value={value}
+              disabled={!canToggle || onForEveryone}
+            />
+          </div>
+        )
+      })}
+
+      <div className="rp-tc-state-item">
+        <span className="rp-tc-state-item-name">{t('tc_guests')}</span>
+
+        <TrackChangesToggle
+          id="track-changes-guests"
+          description={t('track_changes_for_guests')}
+          handleToggle={() =>
+            saveTrackChanges({
+              on_for: trackChangesValues,
+              on_for_guests: !onForGuests,
+            })
+          }
+          value={onForGuests}
+          disabled={!canToggle || onForEveryone}
+        />
+      </div>
+    </div>
+  )
+}

+ 74 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel.tsx

@@ -0,0 +1,74 @@
+import { FC, memo, useState } from 'react'
+import {
+  useCodeMirrorStateContext,
+  useCodeMirrorViewContext,
+} from '@/features/source-editor/components/codemirror-editor'
+import ReviewPanelTabs from './review-panel-tabs'
+import ReviewPanelHeader from './review-panel-header'
+import ReviewPanelCurrentFile from './review-panel-current-file'
+import { ReviewPanelOverview } from './review-panel-overview'
+import classnames from 'classnames'
+
+export type SubView = 'cur_file' | 'overview'
+
+export const PANEL_WIDTH = 230
+export const PANEL_MINI_WIDTH = 20
+
+const ReviewPanel: FC<{ mini: boolean }> = ({ mini }) => {
+  const view = useCodeMirrorViewContext()
+  // eslint-disable-next-line no-unused-vars
+  const _state = useCodeMirrorStateContext() // needs to update on editor state changes
+
+  const [subView, setSubView] = useState<SubView>('cur_file')
+
+  const contentRect = view.contentDOM.getBoundingClientRect()
+  const scrollRect = view.scrollDOM.getBoundingClientRect()
+
+  return (
+    <div
+      className="review-panel-container"
+      style={{
+        overflowY: subView === 'overview' ? 'hidden' : undefined,
+        position: subView === 'overview' ? 'sticky' : 'relative',
+        top: subView === 'overview' ? 0 : undefined,
+      }}
+    >
+      <div
+        className={classnames('review-panel-new', {
+          'review-panel-mini': mini,
+        })}
+        style={{
+          flexShrink: 0,
+          minHeight: subView === 'cur_file' ? contentRect.height : 'auto',
+          height: subView === 'overview' ? '100%' : undefined,
+          overflow: subView === 'overview' ? 'hidden' : undefined,
+          width: mini ? PANEL_MINI_WIDTH : PANEL_WIDTH,
+        }}
+      >
+        {!mini && (
+          <ReviewPanelHeader top={scrollRect.top - 40} width={PANEL_WIDTH} />
+        )}
+
+        {subView === 'cur_file' && <ReviewPanelCurrentFile />}
+        {subView === 'overview' && <ReviewPanelOverview />}
+
+        <div
+          className="review-panel-footer"
+          style={{
+            position: 'fixed',
+            top: scrollRect.bottom - 66,
+            zIndex: 1,
+            background: '#fafafa',
+            borderTop: 'solid 1px #d9d9d9',
+            width: PANEL_WIDTH,
+            display: mini ? 'none' : 'flex',
+          }}
+        >
+          <ReviewPanelTabs subView={subView} setSubView={setSubView} />
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default memo(ReviewPanel)

+ 56 - 0
services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx

@@ -0,0 +1,56 @@
+import {
+  createContext,
+  FC,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react'
+import { getJSON } from '@/infrastructure/fetch-json'
+import { useProjectContext } from '@/shared/context/project-context'
+import { UserId } from '../../../../../types/user'
+
+type ChangesUser = {
+  id: UserId
+  email: string
+  first_name?: string
+  last_name?: string
+}
+
+export type ChangesUsers = Map<UserId, ChangesUser>
+
+export const ChangesUsersContext = createContext<ChangesUsers | undefined>(
+  undefined
+)
+
+export const ChangesUsersProvider: FC = ({ children }) => {
+  const { _id: projectId, members, owner } = useProjectContext()
+
+  const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
+
+  useEffect(() => {
+    getJSON<ChangesUser[]>(`/project/${projectId}/changes/users`).then(data =>
+      setChangesUsers(new Map(data.map(item => [item.id, item])))
+    )
+  }, [projectId])
+
+  // add the project owner and members to the changes users data
+  const value = useMemo(() => {
+    const value: ChangesUsers = new Map(changesUsers)
+    value.set(owner._id, { ...owner, id: owner._id })
+    for (const member of members) {
+      value.set(member._id, { ...member, id: member._id })
+    }
+    return value
+  }, [members, owner, changesUsers])
+
+  return (
+    <ChangesUsersContext.Provider value={value}>
+      {children}
+    </ChangesUsersContext.Provider>
+  )
+}
+
+export const useChangesUsersContext = () => {
+  return useContext(ChangesUsersContext)
+}

+ 141 - 0
services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx

@@ -0,0 +1,141 @@
+import {
+  createContext,
+  FC,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react'
+import useScopeValue from '@/shared/hooks/use-scope-value'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+import {
+  Change,
+  CommentOperation,
+  EditOperation,
+} from '../../../../../types/change'
+import RangesTracker from '@overleaf/ranges-tracker'
+import { rejectChanges } from '@/features/source-editor/extensions/track-changes'
+import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
+
+export type Ranges = {
+  docId: string
+  total: number
+  changes: Change<EditOperation>[]
+  comments: Change<CommentOperation>[]
+}
+
+export const RangesContext = createContext<Ranges | undefined>(undefined)
+
+type RangesActions = {
+  acceptChanges: (...ids: string[]) => void
+  rejectChanges: (...ids: string[]) => void
+}
+
+const buildRanges = (currentDoc: DocumentContainer | null) => {
+  if (currentDoc?.ranges) {
+    return {
+      ...currentDoc.ranges,
+      docId: currentDoc.doc_id,
+      total:
+        currentDoc.ranges.changes.length + currentDoc.ranges.comments.length,
+    }
+  }
+}
+
+const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
+
+export const RangesProvider: FC = ({ children }) => {
+  const view = useCodeMirrorViewContext()
+
+  const [currentDoc] = useScopeValue<DocumentContainer | null>(
+    'editor.sharejs_doc'
+  )
+
+  const [ranges, setRanges] = useState<Ranges | undefined>(() =>
+    buildRanges(currentDoc)
+  )
+
+  // rebuild the ranges when the current doc changes
+  useEffect(() => {
+    setRanges(buildRanges(currentDoc))
+  }, [currentDoc])
+
+  useEffect(() => {
+    if (currentDoc) {
+      const listener = () => {
+        setRanges(buildRanges(currentDoc))
+      }
+
+      // currentDoc.on('ranges:clear.cm6', listener)
+      currentDoc.on('ranges:redraw.cm6', listener)
+      currentDoc.on('ranges:dirty.cm6', listener)
+
+      return () => {
+        // currentDoc.off('ranges:clear.cm6')
+        currentDoc.off('ranges:redraw.cm6')
+        currentDoc.off('ranges:dirty.cm6')
+      }
+    }
+  }, [currentDoc])
+
+  // TODO: move this into DocumentContainer?
+  useEffect(() => {
+    if (currentDoc) {
+      const regenerateTrackChangesId = (doc: DocumentContainer) => {
+        if (doc.ranges) {
+          const inflight = doc.ranges.getIdSeed()
+          const pending = RangesTracker.generateIdSeed()
+          doc.ranges.setIdSeed(pending)
+          doc.setTrackChangesIdSeeds({ pending, inflight })
+        }
+      }
+
+      currentDoc.on('flipped_pending_to_inflight', () =>
+        regenerateTrackChangesId(currentDoc)
+      )
+
+      regenerateTrackChangesId(currentDoc)
+
+      return () => {
+        currentDoc.off('flipped_pending_to_inflight')
+      }
+    }
+  }, [currentDoc])
+
+  const actions = useMemo(
+    () => ({
+      acceptChanges(...ids: string[]) {
+        if (currentDoc?.ranges) {
+          currentDoc.ranges.removeChangeIds(ids)
+          setRanges(buildRanges(currentDoc))
+        }
+      },
+      rejectChanges(...ids: string[]) {
+        if (currentDoc?.ranges) {
+          view.dispatch(rejectChanges(view.state, currentDoc.ranges, ids))
+        }
+      },
+    }),
+    [currentDoc, view]
+  )
+
+  return (
+    <RangesActionsContext.Provider value={actions}>
+      <RangesContext.Provider value={ranges}>{children}</RangesContext.Provider>
+    </RangesActionsContext.Provider>
+  )
+}
+
+export const useRangesContext = () => {
+  return useContext(RangesContext)
+}
+
+export const useRangesActionsContext = () => {
+  const context = useContext(RangesActionsContext)
+  if (!context) {
+    throw new Error(
+      'useRangesActionsContext is only available inside RangesProvider'
+    )
+  }
+  return context
+}

+ 22 - 0
services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx

@@ -0,0 +1,22 @@
+import { FC } from 'react'
+import { RangesProvider } from './ranges-context'
+import { ChangesUsersProvider } from './changes-users-context'
+import { TrackChangesStateProvider } from './track-changes-state-context'
+import { ThreadsProvider } from './threads-context'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+
+export const ReviewPanelProviders: FC = ({ children }) => {
+  if (!isSplitTestEnabled('review-panel-redesign')) {
+    return <>{children}</>
+  }
+
+  return (
+    <ChangesUsersProvider>
+      <TrackChangesStateProvider>
+        <ThreadsProvider>
+          <RangesProvider>{children}</RangesProvider>
+        </ThreadsProvider>
+      </TrackChangesStateProvider>
+    </ChangesUsersProvider>
+  )
+}

+ 291 - 0
services/web/frontend/js/features/review-panel-new/context/threads-context.tsx

@@ -0,0 +1,291 @@
+import {
+  createContext,
+  FC,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react'
+import { useProjectContext } from '@/shared/context/project-context'
+import {
+  CommentId,
+  ThreadId,
+} from '../../../../../types/review-panel/review-panel'
+import { ReviewPanelCommentThread } from '../../../../../types/review-panel/comment-thread'
+import { useConnectionContext } from '@/features/ide-react/context/connection-context'
+import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
+import { ReviewPanelCommentThreadMessageApi } from '../../../../../types/review-panel/api'
+import { UserId } from '../../../../../types/user'
+import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
+import RangesTracker from '@overleaf/ranges-tracker'
+import { CommentOperation } from '../../../../../types/change'
+import useScopeValue from '@/shared/hooks/use-scope-value'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+
+export type Threads = Record<ThreadId, ReviewPanelCommentThread>
+
+export const ThreadsContext = createContext<Threads | undefined>(undefined)
+
+type ThreadsActions = {
+  addComment: (pos: number, text: string, content: string) => Promise<void>
+  resolveThread: (threadId: ThreadId) => Promise<void>
+  reopenThread: (threadId: ThreadId) => Promise<void>
+  deleteThread: (threadId: ThreadId) => Promise<void>
+  addMessage: (threadId: ThreadId, content: string) => Promise<void>
+  editMessage: (
+    threadId: ThreadId,
+    commentId: CommentId,
+    content: string
+  ) => Promise<void>
+  deleteMessage: (threadId: ThreadId, commentId: CommentId) => Promise<void>
+}
+
+const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
+  undefined
+)
+
+export const ThreadsProvider: FC = ({ children }) => {
+  const { _id: projectId } = useProjectContext()
+
+  const [currentDoc] = useScopeValue<DocumentContainer | null>(
+    'editor.sharejs_doc'
+  )
+
+  // const [error, setError] = useState<Error>()
+  const [data, setData] = useState<Threads>()
+
+  // load the initial threads data
+  useEffect(() => {
+    const abortController = new AbortController()
+
+    getJSON(`/project/${projectId}/threads`, {
+      signal: abortController.signal,
+    }).then(data => {
+      setData(data)
+    })
+    // .catch(error => {
+    //   setError(error)
+    // })
+  }, [projectId])
+
+  const { socket } = useConnectionContext()
+
+  useSocketListener(
+    socket,
+    'new-comment',
+    useCallback(
+      (threadId: ThreadId, comment: ReviewPanelCommentThreadMessageApi) => {
+        setData(value => {
+          if (value) {
+            const { submitting, ...thread } = value[threadId] ?? {
+              messages: [],
+            }
+
+            return {
+              ...value,
+              [threadId]: {
+                ...thread,
+                messages: [
+                  ...thread.messages,
+                  {
+                    ...comment,
+                    user: comment.user, // TODO
+                    timestamp: new Date(comment.timestamp),
+                  },
+                ],
+              },
+            }
+          }
+        })
+      },
+      []
+    )
+  )
+
+  useSocketListener(
+    socket,
+    'edit-message',
+    useCallback((threadId: ThreadId, commentId: CommentId, content: string) => {
+      setData(value => {
+        if (value) {
+          const thread = value[threadId] ?? { messages: [] }
+
+          return {
+            ...value,
+            [threadId]: {
+              ...thread,
+              messages: thread.messages.map(message =>
+                message.id === commentId ? { ...message, content } : message
+              ),
+            },
+          }
+        }
+      })
+    }, [])
+  )
+
+  useSocketListener(
+    socket,
+    'delete-message',
+    useCallback((threadId: ThreadId, commentId: CommentId) => {
+      setData(value => {
+        if (value) {
+          const thread = value[threadId] ?? { messages: [] }
+
+          return {
+            ...value,
+            [threadId]: {
+              ...thread,
+              messages: thread.messages.filter(
+                message => message.id !== commentId
+              ),
+            },
+          }
+        }
+      })
+    }, [])
+  )
+
+  useSocketListener(
+    socket,
+    'resolve-thread',
+    useCallback(
+      (
+        threadId: ThreadId,
+        user: { email: string; first_name: string; id: UserId }
+      ) => {
+        setData(value => {
+          if (value) {
+            const thread = value[threadId] ?? { messages: [] }
+
+            return {
+              ...value,
+              [threadId]: {
+                ...thread,
+                resolved: true,
+                resolved_by_user: user, // TODO
+                resolved_at: new Date().toISOString(),
+              },
+            }
+          }
+        })
+      },
+      []
+    )
+  )
+
+  useSocketListener(
+    socket,
+    'reopen-thread',
+    useCallback((threadId: ThreadId) => {
+      setData(value => {
+        if (value) {
+          const thread = value[threadId] ?? { messages: [] }
+
+          return {
+            ...value,
+            [threadId]: {
+              ...thread,
+              resolved: undefined,
+              resolved_by_user: undefined,
+              resolved_at: undefined,
+            },
+          }
+        }
+      })
+    }, [])
+  )
+
+  useSocketListener(
+    socket,
+    'delete-thread',
+    useCallback((threadId: ThreadId) => {
+      setData(value => {
+        if (value) {
+          const _value = { ...value }
+          delete _value[threadId]
+          return _value
+        }
+      })
+    }, [])
+  )
+
+  const actions = useMemo(
+    () => ({
+      async addComment(pos: number, text: string, content: string) {
+        const threadId = RangesTracker.generateId() as ThreadId
+
+        await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
+          body: { content },
+        })
+
+        const op: CommentOperation = {
+          c: text,
+          p: pos,
+          t: threadId,
+        }
+
+        currentDoc?.submitOp(op)
+      },
+      async resolveThread(threadId: string) {
+        await postJSON(
+          `/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}/resolve`
+        )
+      },
+      async reopenThread(threadId: string) {
+        await postJSON(
+          `/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}/reopen`
+        )
+      },
+      async deleteThread(threadId: string) {
+        await deleteJSON(
+          `/project/${projectId}/doc/${currentDoc?.doc_id}/thread/${threadId}`
+        )
+        currentDoc?.ranges?.removeCommentId(threadId)
+      },
+      async addMessage(threadId: ThreadId, content: string) {
+        await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
+          body: { content },
+        })
+        // TODO: error_submitting_comment
+      },
+      async editMessage(
+        threadId: ThreadId,
+        commentId: CommentId,
+        content: string
+      ) {
+        await postJSON(
+          `/project/${projectId}/thread/${threadId}/messages/${commentId}/edit`,
+          { body: { content } }
+        )
+      },
+      async deleteMessage(threadId: ThreadId, commentId: CommentId) {
+        await deleteJSON(
+          `/project/${projectId}/thread/${threadId}/messages/${commentId}`
+        )
+      },
+    }),
+    [currentDoc, projectId]
+  )
+
+  return (
+    <ThreadsActionsContext.Provider value={actions}>
+      <ThreadsContext.Provider value={data}>{children}</ThreadsContext.Provider>
+    </ThreadsActionsContext.Provider>
+  )
+}
+
+export const useThreadsContext = () => {
+  return useContext(ThreadsContext)
+}
+
+export const useThreadsActionsContext = () => {
+  const context = useContext(ThreadsActionsContext)
+  if (!context) {
+    throw new Error(
+      'useThreadsActionsContext is only available inside ThreadsProvider'
+    )
+  }
+  return context
+}

+ 43 - 0
services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx

@@ -0,0 +1,43 @@
+import { UserId } from '../../../../../types/user'
+import { createContext, FC, useContext, useEffect, useState } from 'react'
+import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
+import { useConnectionContext } from '@/features/ide-react/context/connection-context'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useUserContext } from '@/shared/context/user-context'
+
+export type TrackChangesState = boolean | Record<UserId | '__guests__', boolean>
+
+export const TrackChangesStateContext = createContext<
+  TrackChangesState | undefined
+>(undefined)
+
+export const TrackChangesStateProvider: FC = ({ children }) => {
+  const { socket } = useConnectionContext()
+  const project = useProjectContext()
+  const user = useUserContext()
+  const { setWantTrackChanges } = useEditorManagerContext()
+
+  // TODO: update project.trackChangesState instead?
+  const [value, setValue] = useState<TrackChangesState>(
+    project.trackChangesState ?? false
+  )
+
+  useSocketListener(socket, 'toggle-track-changes', setValue)
+
+  useEffect(() => {
+    setWantTrackChanges(
+      value === true || (value !== false && value[user.id ?? '__guests__'])
+    )
+  }, [setWantTrackChanges, value, user.id])
+
+  return (
+    <TrackChangesStateContext.Provider value={value}>
+      {children}
+    </TrackChangesStateContext.Provider>
+  )
+}
+
+export const useTrackChangesStateContext = () => {
+  return useContext(TrackChangesStateContext)
+}

+ 22 - 0
services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts

@@ -0,0 +1,22 @@
+import { useCallback } from 'react'
+import { DocId } from '../../../../../types/project-settings'
+import { useProjectContext } from '../../../shared/context/project-context'
+import usePersistedState from '../../../shared/hooks/use-persisted-state'
+
+export default function useOverviewFileCollapsed(docId: DocId) {
+  const { _id: projectId } = useProjectContext()
+  const [collapsedDocs, setCollapsedDocs] = usePersistedState<
+    Record<DocId, boolean>
+  >(`docs_collapsed_state:${projectId}`, {}, false, true)
+
+  const toggleCollapsed = useCallback(() => {
+    setCollapsedDocs((collapsedDocs: Record<DocId, boolean>) => {
+      return {
+        ...collapsedDocs,
+        [docId]: !collapsedDocs[docId],
+      }
+    })
+  }, [docId, setCollapsedDocs])
+
+  return { collapsed: collapsedDocs[docId], toggleCollapsed }
+}

+ 15 - 0
services/web/frontend/js/features/review-panel-new/utils/build-name.ts

@@ -0,0 +1,15 @@
+export const buildName = (user?: {
+  first_name?: string
+  last_name?: string
+  email?: string
+}) => {
+  const name = [user?.first_name, user?.last_name].filter(Boolean).join(' ')
+
+  if (name) {
+    return name
+  }
+  if (user?.email) {
+    return user.email.split('@')[0]
+  }
+  return 'Unknown'
+}

+ 15 - 0
services/web/frontend/js/features/review-panel-new/utils/can-aggregate.ts

@@ -0,0 +1,15 @@
+import {
+  Change,
+  DeleteOperation,
+  InsertOperation,
+} from '../../../../../types/change'
+
+export const canAggregate = (
+  deletion: Change<DeleteOperation>,
+  insertion: Change<InsertOperation>
+) =>
+  deletion.metadata?.user_id &&
+  // same user
+  deletion.metadata?.user_id === insertion.metadata?.user_id &&
+  // same position
+  deletion.op.p === insertion.op.p + insertion.op.i.length

+ 7 - 0
services/web/frontend/js/features/review-panel-new/utils/is-focused.ts

@@ -0,0 +1,7 @@
+import { AnyOperation } from '../../../../../types/change'
+import { SelectionRange } from '@codemirror/state'
+import { visibleTextLength } from '@/utils/operations'
+
+export const isFocused = (op: AnyOperation, range: SelectionRange): boolean => {
+  return range.to >= op.p && range.from <= op.p + visibleTextLength(op)
+}

+ 7 - 0
services/web/frontend/js/features/review-panel-new/utils/is-in-viewport.tsx

@@ -0,0 +1,7 @@
+import { EditorView } from '@codemirror/view'
+import { Change } from '../../../../../types/change'
+
+export const isInViewport =
+  (view: EditorView) =>
+  (change: Change): boolean =>
+    change.op.p >= view.viewport.from && change.op.p <= view.viewport.to

+ 77 - 0
services/web/frontend/js/features/review-panel-new/utils/position-items.ts

@@ -0,0 +1,77 @@
+import { debounce } from 'lodash'
+
+export const positionItems = debounce(
+  (
+    element: HTMLDivElement,
+    containerElement: HTMLDivElement,
+    previousFocusedItemIndex: number
+  ) => {
+    const scrollRect = containerElement.getBoundingClientRect()
+
+    const items = Array.from(
+      element.querySelectorAll<HTMLDivElement>('.review-panel-entry')
+    )
+
+    items.sort((a, b) => Number(a.dataset.pos) - Number(b.dataset.pos))
+
+    if (!items.length) {
+      return
+    }
+
+    let focusedItemIndex = items.findIndex(item =>
+      item.classList.contains('review-panel-entry-focused')
+    )
+    if (focusedItemIndex === -1) {
+      focusedItemIndex = previousFocusedItemIndex
+    }
+
+    // TODO: editorPadding?
+    const topDiff = scrollRect.top - 80
+
+    const focusedItem = items[focusedItemIndex]
+    if (!focusedItem) {
+      return
+    }
+    const focusedItemTop = Number(focusedItem.dataset.top)
+    focusedItem.style.top = `${focusedItemTop + topDiff}px`
+    focusedItem.style.visibility = 'visible'
+    const focusedItemRect = focusedItem.getBoundingClientRect()
+
+    // above the focused item
+    let topLimit = focusedItemTop
+    for (let i = focusedItemIndex - 1; i >= 0; i--) {
+      const item = items[i]
+      const rect = item.getBoundingClientRect()
+      let top = Number(item.dataset.top)
+      const bottom = top + rect.height
+      if (bottom > topLimit) {
+        top = topLimit - rect.height - 10
+      }
+      item.style.top = `${top + topDiff}px`
+      item.style.visibility = 'visible'
+      topLimit = top
+    }
+
+    // below the focused item
+    let bottomLimit = focusedItemTop + focusedItemRect.height
+    for (let i = focusedItemIndex + 1; i < items.length; i++) {
+      const item = items[i]
+      const rect = item.getBoundingClientRect()
+      let top = Number(item.dataset.top)
+      if (top < bottomLimit) {
+        top = bottomLimit + 10
+      }
+      item.style.top = `${top + topDiff}px`
+      item.style.visibility = 'visible'
+      bottomLimit = top + rect.height
+    }
+
+    return {
+      focusedItemIndex,
+      min: topLimit,
+      max: bottomLimit,
+    }
+  },
+  100,
+  { leading: false, trailing: true, maxWait: 1000 }
+)

+ 22 - 19
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

@@ -18,8 +18,9 @@ import { dispatchTimer } from '../../../infrastructure/cm6-performance'
 
 import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
 import { FigureModal } from './figure-modal/figure-modal'
-import ReviewPanel from './review-panel/review-panel'
 import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
+import { ReviewPanelProviders } from '@/features/review-panel-new/context/review-panel-providers'
+import { ReviewPanelMigration } from '@/features/source-editor/components/review-panel/review-panel-migration'
 
 const sourceEditorComponents = importOverleafModules(
   'sourceEditorComponents'
@@ -61,24 +62,26 @@ function CodeMirrorEditor() {
   return (
     <CodeMirrorStateContext.Provider value={state}>
       <CodeMirrorViewContextProvider value={viewRef.current}>
-        <CodemirrorOutline />
-        <CodeMirrorView />
-        <FigureModal />
-        <CodeMirrorSearch />
-        <CodeMirrorToolbar />
-        {sourceEditorToolbarComponents.map(
-          ({ import: { default: Component }, path }) => (
-            <Component key={path} />
-          )
-        )}
-        <CodeMirrorCommandTooltip />
-
-        {shouldShowReviewPanel && <ReviewPanel />}
-        {sourceEditorComponents.map(
-          ({ import: { default: Component }, path }) => (
-            <Component key={path} />
-          )
-        )}
+        <ReviewPanelProviders>
+          <CodemirrorOutline />
+          <CodeMirrorView />
+          <FigureModal />
+          <CodeMirrorSearch />
+          <CodeMirrorToolbar />
+          {sourceEditorToolbarComponents.map(
+            ({ import: { default: Component }, path }) => (
+              <Component key={path} />
+            )
+          )}
+          <CodeMirrorCommandTooltip />
+
+          {shouldShowReviewPanel && <ReviewPanelMigration />}
+          {sourceEditorComponents.map(
+            ({ import: { default: Component }, path }) => (
+              <Component key={path} />
+            )
+          )}
+        </ReviewPanelProviders>
       </CodeMirrorViewContextProvider>
     </CodeMirrorStateContext.Provider>
   )

+ 0 - 11
services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx

@@ -9,8 +9,6 @@ import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-heigh
 import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
 import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
 import Entry from './entry'
-import EmptyState from './empty-state'
-import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 function CurrentFileContainer() {
   const { entries, openDocId } = useReviewPanelValueContext()
@@ -25,19 +23,10 @@ function CurrentFileContainer() {
     >
   }, [currentDocEntries])
 
-  const enableEmptyState = useFeatureFlag('review-panel-redesign')
-
-  const showEmptyState =
-    enableEmptyState &&
-    objectEntries.filter(
-      ([key]) => key !== 'add-comment' && key !== 'bulk-actions'
-    ).length === 0
-
   return (
     <Container className="rp-current-file-container">
       <div className="review-panel-tools">
         <Toolbar />
-        {showEmptyState && <EmptyState />}
         <Nav />
       </div>
       <Toggler />

+ 1 - 19
services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx

@@ -6,33 +6,15 @@ import Icon from '../../../../shared/components/icon'
 import OverviewFile from './overview-file'
 import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
-import { memo, useMemo } from 'react'
-import EmptyState from './empty-state'
-import { useFeatureFlag } from '@/shared/context/split-test-context'
+import { memo } from 'react'
 
 function OverviewContainer() {
-  const { entries } = useReviewPanelValueContext()
-
   const { isOverviewLoading } = useReviewPanelValueContext()
   const { docs } = useFileTreeData()
 
-  const entryCount = useMemo(() => {
-    return docs
-      ?.map(doc => {
-        const docEntries = entries[doc.doc.id] ?? {}
-        return Object.keys(docEntries).filter(
-          key => key !== 'add-comment' && key !== 'bulk-actions'
-        ).length
-      })
-      .reduce((acc, curr) => acc + curr, 0)
-  }, [docs, entries])
-
-  const enableEmptyState = useFeatureFlag('review-panel-redesign')
-
   return (
     <Container>
       <Toggler />
-      {enableEmptyState && entryCount === 0 && <EmptyState />}
       <Toolbar />
       <div
         className="rp-entry-list"

+ 37 - 0
services/web/frontend/js/features/source-editor/components/review-panel/review-panel-content.tsx

@@ -0,0 +1,37 @@
+import { FC, memo } from 'react'
+import EditorWidgets from '@/features/source-editor/components/review-panel/editor-widgets/editor-widgets'
+import { isCurrentFileView } from '@/features/source-editor/utils/sub-view'
+import CurrentFileContainer from '@/features/source-editor/components/review-panel/current-file-container'
+import OverviewContainer from '@/features/source-editor/components/review-panel/overview-container'
+import { useReviewPanelValueContext } from '@/features/source-editor/context/review-panel/review-panel-context'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import classnames from 'classnames'
+
+const ReviewPanelContent: FC = () => {
+  const { subView, loadingThreads, layoutToLeft } = useReviewPanelValueContext()
+  const { reviewPanelOpen, miniReviewPanelVisible } = useLayoutContext()
+
+  const className = classnames('review-panel-wrapper', {
+    'rp-state-current-file': subView === 'cur_file',
+    'rp-state-current-file-expanded': subView === 'cur_file' && reviewPanelOpen,
+    'rp-state-current-file-mini': subView === 'cur_file' && !reviewPanelOpen,
+    'rp-state-overview': subView === 'overview',
+    'rp-size-mini': miniReviewPanelVisible,
+    'rp-size-expanded': reviewPanelOpen,
+    'rp-layout-left': layoutToLeft,
+    'rp-loading-threads': loadingThreads,
+  })
+
+  return (
+    <div className={className}>
+      <EditorWidgets />
+      {isCurrentFileView(subView) ? (
+        <CurrentFileContainer />
+      ) : (
+        <OverviewContainer />
+      )}
+    </div>
+  )
+}
+
+export default memo(ReviewPanelContent)

+ 19 - 0
services/web/frontend/js/features/source-editor/components/review-panel/review-panel-migration.tsx

@@ -0,0 +1,19 @@
+import React, { FC, lazy, Suspense } from 'react'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import LoadingSpinner from '@/shared/components/loading-spinner'
+
+const ReviewPanel = lazy(() => import('./review-panel'))
+
+const ReviewPanelNew = lazy(
+  () => import('../../../review-panel-new/components/review-panel-container')
+)
+
+export const ReviewPanelMigration: FC = () => {
+  const newReviewPanel = useFeatureFlag('review-panel-redesign')
+
+  return (
+    <Suspense fallback={<LoadingSpinner delay={500} />}>
+      {newReviewPanel ? <ReviewPanelNew /> : <ReviewPanel />}
+    </Suspense>
+  )
+}

+ 7 - 60
services/web/frontend/js/features/source-editor/components/review-panel/review-panel.tsx

@@ -1,70 +1,17 @@
 import ReactDOM from 'react-dom'
-import EditorWidgets from './editor-widgets/editor-widgets'
-import CurrentFileContainer from './current-file-container'
-import OverviewContainer from './overview-container'
 import { useCodeMirrorViewContext } from '../codemirror-editor'
-import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
-import { isCurrentFileView } from '../../utils/sub-view'
-import { useLayoutContext } from '@/shared/context/layout-context'
-import classnames from 'classnames'
-import { lazy, memo } from 'react'
-import { SubView } from '../../../../../../types/review-panel/review-panel'
-
-type ReviewPanelViewProps = {
-  parentDomNode: Element
-}
-
-function ReviewPanelView({ parentDomNode }: ReviewPanelViewProps) {
-  return ReactDOM.createPortal(<ReviewPanelContainer />, parentDomNode)
-}
-
-const ReviewPanelContainer = memo(() => {
-  const { subView, loadingThreads, layoutToLeft } = useReviewPanelValueContext()
-  const { reviewPanelOpen, miniReviewPanelVisible } = useLayoutContext()
-
-  const className = classnames('review-panel-wrapper', {
-    'rp-state-current-file': subView === 'cur_file',
-    'rp-state-current-file-expanded': subView === 'cur_file' && reviewPanelOpen,
-    'rp-state-current-file-mini': subView === 'cur_file' && !reviewPanelOpen,
-    'rp-state-overview': subView === 'overview',
-    'rp-size-mini': miniReviewPanelVisible,
-    'rp-size-expanded': reviewPanelOpen,
-    'rp-layout-left': layoutToLeft,
-    'rp-loading-threads': loadingThreads,
-  })
-
-  return (
-    <div className={className}>
-      <ReviewPanelContent subView={subView} />
-    </div>
-  )
-})
-ReviewPanelContainer.displayName = 'ReviewPanelContainer'
-
-const ReviewPanelContent = memo<{ subView: SubView }>(({ subView }) => (
-  <>
-    <EditorWidgets />
-    {isCurrentFileView(subView) ? (
-      <CurrentFileContainer />
-    ) : (
-      <OverviewContainer />
-    )}
-  </>
-))
-ReviewPanelContent.displayName = 'ReviewPanelContent'
-
-const ReviewPanelProvider = lazy(
-  () =>
-    import('@/features/ide-react/context/review-panel/review-panel-provider')
-)
+import { ReviewPanelProvider } from '../../context/review-panel/review-panel-context'
+import { memo } from 'react'
+import ReviewPanelContent from '@/features/source-editor/components/review-panel/review-panel-content'
 
 function ReviewPanel() {
   const view = useCodeMirrorViewContext()
 
-  return (
+  return ReactDOM.createPortal(
     <ReviewPanelProvider>
-      <ReviewPanelView parentDomNode={view.scrollDOM} />
-    </ReviewPanelProvider>
+      <ReviewPanelContent />
+    </ReviewPanelProvider>,
+    view.scrollDOM
   )
 }
 

+ 13 - 0
services/web/frontend/js/features/source-editor/context/review-panel/review-panel-context.tsx

@@ -1,5 +1,6 @@
 import { createContext, useContext } from 'react'
 import type { ReviewPanelState } from './types/review-panel-state'
+import useReviewPanelState from '@/features/ide-react/context/review-panel/hooks/use-review-panel-state'
 
 export const ReviewPanelValueContext = createContext<
   ReviewPanelState['values'] | undefined
@@ -9,6 +10,18 @@ export const ReviewPanelUpdaterFnsContext = createContext<
   ReviewPanelState['updaterFns'] | undefined
 >(undefined)
 
+export const ReviewPanelProvider: React.FC = ({ children }) => {
+  const { values, updaterFns } = useReviewPanelState()
+
+  return (
+    <ReviewPanelValueContext.Provider value={values}>
+      <ReviewPanelUpdaterFnsContext.Provider value={updaterFns}>
+        {children}
+      </ReviewPanelUpdaterFnsContext.Provider>
+    </ReviewPanelValueContext.Provider>
+  )
+}
+
 export function useReviewPanelValueContext() {
   const context = useContext(ReviewPanelValueContext)
 

+ 7 - 6
services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts

@@ -20,6 +20,7 @@ import { ThreadId } from '../../../../../../types/review-panel/review-panel'
 import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
 import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 import { updateHasEffect } from '@/features/source-editor/utils/effects'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
 
 // With less than this number of entries, don't bother culling to avoid
 // little UI jumps when scrolling.
@@ -85,7 +86,11 @@ export type UpdateType =
 export const createChangeManager = (
   view: EditorView,
   currentDoc: DocumentContainer
-): ChangeManager => {
+): ChangeManager | undefined => {
+  if (isSplitTestEnabled('review-panel-redesign')) {
+    return undefined
+  }
+
   /**
    * Calculate the screen coordinates of each entry (change or comment),
    * for use in the review panel.
@@ -261,11 +266,7 @@ export const createChangeManager = (
         const text = view.state.doc.sliceString(from, to)
 
         if (text !== content) {
-          throw new Error(
-            `Op to be removed (${JSON.stringify(
-              change.op
-            )}) does not match editor text '${text}'`
-          )
+          throw new Error(`Op to be removed does not match editor text`)
         }
 
         return { from, to, insert: '' }

+ 194 - 25
services/web/frontend/js/features/source-editor/extensions/track-changes.ts

@@ -4,6 +4,7 @@ import {
   StateEffect,
   StateField,
   Transaction,
+  TransactionSpec,
 } from '@codemirror/state'
 import {
   Decoration,
@@ -21,14 +22,39 @@ import {
   StoredComment,
 } from './changes/comments'
 import { invertedEffects } from '@codemirror/commands'
-import { Change, DeleteOperation } from '../../../../../types/change'
+import {
+  Change,
+  DeleteOperation,
+  EditOperation,
+} from '../../../../../types/change'
 import { ChangeManager } from './changes/change-manager'
 import { debugConsole } from '@/utils/debugging'
-import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
+import {
+  isCommentOperation,
+  isDeleteOperation,
+  isInsertOperation,
+} from '@/utils/operations'
 import {
   DocumentContainer,
   RangesTrackerWithResolvedThreadIds,
 } from '@/features/ide-react/editor/document-container'
+import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
+import { Ranges } from '@/features/review-panel-new/context/ranges-context'
+import { Threads } from '@/features/review-panel-new/context/threads-context'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+
+type RangesData = {
+  ranges: Ranges
+  threads: Threads
+}
+
+const updateRangesEffect = StateEffect.define<RangesData>()
+
+export const updateRanges = (data: RangesData): TransactionSpec => {
+  return {
+    effects: updateRangesEffect.of(data),
+  }
+}
 
 const clearChangesEffect = StateEffect.define()
 const buildChangesEffect = StateEffect.define()
@@ -46,7 +72,9 @@ const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
 
 type Options = {
   currentDoc: DocumentContainer
-  loadingThreads: boolean
+  loadingThreads?: boolean
+  ranges?: Ranges
+  threads?: Threads
 }
 
 /**
@@ -54,8 +82,8 @@ type Options = {
  * and produces decorations for tracked changes and comments.
  */
 export const trackChanges = (
-  { currentDoc, loadingThreads }: Options,
-  changeManager: ChangeManager
+  { currentDoc, loadingThreads, ranges, threads }: Options,
+  changeManager?: ChangeManager
 ) => {
   // A state field that stored any comments found within the ranges of a "cut" transaction,
   // to be restored when pasting matching text.
@@ -120,18 +148,20 @@ export const trackChanges = (
     cutCommentsState,
 
     // initialize/destroy the change manager, and handle any updates
-    ViewPlugin.define(() => {
-      changeManager.initialize()
-
-      return {
-        update: update => {
-          changeManager.handleUpdate(update)
-        },
-        destroy: () => {
-          changeManager.destroy()
-        },
-      }
-    }),
+    changeManager
+      ? ViewPlugin.define(() => {
+          changeManager.initialize()
+
+          return {
+            update: update => {
+              changeManager.handleUpdate(update)
+            },
+            destroy: () => {
+              changeManager.destroy()
+            },
+          }
+        })
+      : [],
 
     // draw change decorations
     ViewPlugin.define<
@@ -140,10 +170,20 @@ export const trackChanges = (
       }
     >(
       () => {
+        let decorations = Decoration.none
+        if (isSplitTestEnabled('review-panel-redesign')) {
+          if (ranges && threads) {
+            decorations = buildChangeDecorations(currentDoc, {
+              ranges,
+              threads,
+            })
+          }
+        } else if (!loadingThreads) {
+          decorations = buildChangeDecorations(currentDoc)
+        }
+
         return {
-          decorations: loadingThreads
-            ? Decoration.none
-            : buildChangeDecorations(currentDoc),
+          decorations,
           update(update) {
             for (const transaction of update.transactions) {
               this.decorations = this.decorations.map(transaction.changes)
@@ -153,6 +193,11 @@ export const trackChanges = (
                   this.decorations = Decoration.none
                 } else if (effect.is(buildChangesEffect)) {
                   this.decorations = buildChangeDecorations(currentDoc)
+                } else if (effect.is(updateRangesEffect)) {
+                  this.decorations = buildChangeDecorations(
+                    currentDoc,
+                    effect.value
+                  )
                 }
               }
             }
@@ -181,18 +226,23 @@ export const buildChangeMarkers = () => {
   }
 }
 
-const buildChangeDecorations = (currentDoc: DocumentContainer) => {
-  if (!currentDoc.ranges) {
+const buildChangeDecorations = (
+  currentDoc: DocumentContainer,
+  data?: RangesData
+) => {
+  const ranges = data ? data.ranges : currentDoc.ranges
+
+  if (!ranges) {
     return Decoration.none
   }
 
-  const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
+  const changes = [...ranges.changes, ...ranges.comments]
 
   const decorations = []
 
   for (const change of changes) {
     try {
-      decorations.push(...createChangeRange(change, currentDoc))
+      decorations.push(...createChangeRange(change, currentDoc, data))
     } catch (error) {
       // ignore invalid changes
       debugConsole.debug('invalid change position', error)
@@ -251,7 +301,11 @@ class ChangeCalloutWidget extends WidgetType {
   }
 }
 
-const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
+const createChangeRange = (
+  change: Change,
+  currentDoc: DocumentContainer,
+  data?: RangesData
+) => {
   const { id, metadata, op } = change
 
   const from = op.p
@@ -289,6 +343,20 @@ const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
     return []
   }
 
+  if (_isCommentOperation) {
+    if (data) {
+      const thread = data.threads[op.t]
+      if (!thread || thread.resolved) {
+        return []
+      }
+    } else if (
+      (currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
+        .resolvedThreadIds![op.t]
+    ) {
+      return []
+    }
+  }
+
   const opType = _isCommentOperation ? 'c' : 'i'
   const changedText = _isCommentOperation ? op.c : op.i
   const to = from + changedText.length
@@ -316,6 +384,107 @@ const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
   return [calloutWidget.range(from, from), changeMark.range(from, to)]
 }
 
+/**
+ * Remove tracked changes from the range tracker when they're rejected,
+ * and restore the original content
+ */
+export const rejectChanges = (
+  state: EditorState,
+  ranges: DocumentContainer['ranges'],
+  changeIds: string[]
+) => {
+  const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
+
+  if (changes.length === 0) {
+    return {}
+  }
+
+  // When doing bulk rejections, adjacent changes might interact with each other.
+  // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
+  //
+  //     "foo bar baz" -> "foo quux baz"
+  //
+  // The change above will be modeled with two ops, with the insertion going first:
+  //
+  //     foo quux baz
+  //         |--| -> insertion of "quux", op 1, at position 4
+  //             | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
+  //
+  // When rejecting these changes at once, if the insertion is rejected first, we get unexpected
+  // results. What happens is:
+  //
+  //     1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
+  //        starting from position 4;
+  //
+  //           "foo quux baz" -> "foo  baz"
+  //                |--| -> 4 characters to be removed
+  //
+  //     2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
+  //        the word "quuux" was still present).
+  //
+  //            "foo  baz" -> "foo  bazbar"
+  //                     | -> deletion of "bar" is reverted by reinserting "bar" at position 8
+  //
+  // While the intended result would be "foo bar baz", what we get is:
+  //
+  //      "foo  bazbar" (note "bar" readded at position 8)
+  //
+  // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
+  // from position 4. This includes the position where the deletion exists; when that position is
+  // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
+  // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
+  // does so at the outdated position (position 8, which was valid when "quux" was present).
+  //
+  // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
+  // subsequent operations that come after. Reverse sorting the operations based on position will
+  // achieve it; in the case above, it makes sure that the the deletion is reverted first:
+  //
+  //     1) Rejecting the deletion adds the deleted word "bar" at position 8
+  //
+  //            "foo quux baz" -> "foo quuxbar baz"
+  //                                       | -> deletion of "bar" is reverted by
+  //                                            reinserting "bar" at position 8
+  //
+  //     2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
+  //        starting from position 4 and achieves the expected result:
+  //
+  //           "foo quuxbar baz" -> "foo bar baz"
+  //                |--| -> 4 characters to be removed
+
+  changes.sort((a, b) => b.op.p - a.op.p)
+
+  const changesToDispatch = changes.map(change => {
+    const { op } = change
+
+    if (isInsertOperation(op)) {
+      const from = op.p
+      const content = op.i
+      const to = from + content.length
+
+      const text = state.doc.sliceString(from, to)
+
+      if (text !== content) {
+        throw new Error(`Op to be removed does not match editor text`)
+      }
+
+      return { from, to, insert: '' }
+    } else if (isDeleteOperation(op)) {
+      return {
+        from: op.p,
+        to: op.p,
+        insert: op.d,
+      }
+    } else {
+      throw new Error(`unknown change type: ${JSON.stringify(change)}`)
+    }
+  })
+
+  return {
+    changes: changesToDispatch,
+    annotations: [trackChangesAnnotation.of('reject')],
+  }
+}
+
 const trackChangesTheme = EditorView.baseTheme({
   '.cm-line': {
     overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)

+ 5 - 1
services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts

@@ -243,5 +243,9 @@ export function updateChangesTopPadding(update: ViewUpdate): boolean {
 }
 
 export function editorVerticalTopPadding(view: EditorView): number {
-  return view.state.field(overflowPaddingState).top
+  return view.state.field(overflowPaddingState, false)?.top ?? 0
+}
+
+export function editorOverflowPadding(view: EditorView) {
+  return view.state.field(overflowPaddingState, false)
 }

+ 27 - 3
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -62,6 +62,9 @@ import { useMetadataContext } from '@/features/ide-react/context/metadata-contex
 import { useUserContext } from '@/shared/context/user-context'
 import { useReferencesContext } from '@/features/ide-react/context/references-context'
 import { setMathPreview } from '@/features/source-editor/extensions/math-preview'
+import { useRangesContext } from '@/features/review-panel-new/context/ranges-context'
+import { updateRanges } from '@/features/source-editor/extensions/track-changes'
+import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
 
 function useCodeMirrorScope(view: EditorView) {
   const { fileTreeData } = useFileTreeData()
@@ -112,6 +115,9 @@ function useCodeMirrorScope(view: EditorView) {
 
   const { referenceKeys } = useReferencesContext()
 
+  const ranges = useRangesContext()
+  const threads = useThreadsContext()
+
   // build the translation phrases
   const phrases = usePhrases()
 
@@ -162,6 +168,8 @@ function useCodeMirrorScope(view: EditorView) {
     currentDoc,
     trackChanges,
     loadingThreads,
+    threads,
+    ranges,
   })
 
   useEffect(() => {
@@ -170,6 +178,16 @@ function useCodeMirrorScope(view: EditorView) {
     }
   }, [view, currentDoc])
 
+  useEffect(() => {
+    currentDocRef.current.ranges = ranges
+    currentDocRef.current.threads = threads
+    if (ranges && threads) {
+      window.setTimeout(() => {
+        view.dispatch(updateRanges({ ranges, threads }))
+      })
+    }
+  }, [view, ranges, threads])
+
   const docNameRef = useRef(docName)
 
   useEffect(() => {
@@ -225,20 +243,26 @@ function useCodeMirrorScope(view: EditorView) {
   // listen to project metadata (commands, labels and package names) updates
   useEffect(() => {
     metadataRef.current = { ...metadataRef.current, ...metadata }
-    view.dispatch(setMetadata(metadataRef.current))
+    window.setTimeout(() => {
+      view.dispatch(setMetadata(metadataRef.current))
+    })
   }, [view, metadata])
 
   // listen to project reference keys updates
   useEffect(() => {
     metadataRef.current.referenceKeys = referenceKeys
-    view.dispatch(setMetadata(metadataRef.current))
+    window.setTimeout(() => {
+      view.dispatch(setMetadata(metadataRef.current))
+    })
   }, [view, referenceKeys])
 
   // listen to project root folder updates
   useEffect(() => {
     if (fileTreeData) {
       metadataRef.current.fileTreeData = fileTreeData
-      view.dispatch(setMetadata(metadataRef.current))
+      window.setTimeout(() => {
+        view.dispatch(setMetadata(metadataRef.current))
+      })
     }
   }, [view, fileTreeData])
 

+ 7 - 2
services/web/frontend/js/shared/context/types/project-context.tsx

@@ -1,11 +1,12 @@
 import { UserId } from '../../../../../types/user'
 import { PublicAccessLevel } from '../../../../../types/public-access-level'
-import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
 
 export type ProjectContextMember = {
   _id: UserId
   privileges: 'readOnly' | 'readAndWrite'
   email: string
+  first_name: string
+  last_name: string
 }
 
 export type ProjectContextValue = {
@@ -32,13 +33,17 @@ export type ProjectContextValue = {
   owner: {
     _id: UserId
     email: string
+    first_name: string
+    last_name: string
+    privileges: string
+    signUpDate: string
   }
   tags: {
     _id: string
     name: string
     color?: string
   }[]
-  trackChangesState: ReviewPanel.Value<'trackChangesState'>
+  trackChangesState: boolean | Record<UserId | '__guests__', boolean>
 }
 
 export type ProjectContextUpdateValue = Partial<ProjectContextValue>

+ 27 - 0
services/web/frontend/js/utils/operations.ts

@@ -1,6 +1,9 @@
 import {
+  AnyOperation,
+  Change,
   CommentOperation,
   DeleteOperation,
+  EditOperation,
   InsertOperation,
   Operation,
 } from '../../../types/change'
@@ -11,3 +14,27 @@ export const isCommentOperation = (op: Operation): op is CommentOperation =>
   'c' in op
 export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
   'd' in op
+
+export const isInsertChange = (
+  change: Change<EditOperation>
+): change is Change<InsertOperation> => isInsertOperation(change.op)
+
+export const isCommentChange = (
+  change: Change<CommentOperation>
+): change is Change<CommentOperation> => isCommentOperation(change.op)
+
+export const isDeleteChange = (
+  change: Change<EditOperation>
+): change is Change<DeleteOperation> => isDeleteOperation(change.op)
+
+export const visibleTextLength = (op: AnyOperation) => {
+  if (isCommentOperation(op)) {
+    return op.c.length
+  }
+
+  if (isInsertOperation(op)) {
+    return op.i.length
+  }
+
+  return 0
+}

+ 1 - 0
services/web/frontend/stylesheets/app/editor.less

@@ -14,6 +14,7 @@
 @import './editor/math-preview.less';
 @import './editor/hotkeys.less';
 @import './editor/review-panel.less';
+@import './editor/review-panel-new.less';
 @import './editor/publish-modal.less';
 @import './editor/outline.less';
 @import './editor/logs.less';

+ 361 - 0
services/web/frontend/stylesheets/app/editor/review-panel-new.less

@@ -0,0 +1,361 @@
+.review-panel-container {
+  height: 100%;
+  flex-shrink: 0;
+}
+
+.review-panel-new {
+  z-index: 6;
+  flex-shrink: 0;
+  background-color: @neutral-10;
+  border-left: solid 0 @neutral-20;
+  font-family: @font-family-base;
+  line-height: @line-height-base;
+  font-size: @font-size-01;
+  box-sizing: content-box;
+
+  .review-panel-entry {
+    background-color: white;
+    border-radius: @border-radius-base-new;
+    border: 1px solid @neutral-20;
+    padding: @spacing-04;
+    width: calc(100% - @spacing-04);
+    margin-left: @spacing-02;
+    z-index: 1;
+  }
+
+  .review-panel-entry-indicator {
+    display: none;
+  }
+
+  .review-panel-entry-content {
+    display: flex;
+    flex-direction: column;
+    gap: @spacing-04;
+  }
+
+  .review-panel-entry-focused {
+    margin-left: @spacing-01;
+    border: 2px solid @blue-50;
+  }
+  .review-panel-entry-header {
+    display: flex;
+    justify-content: space-between;
+
+    .review-panel-entry-user {
+      color: @blue;
+      font-size: 110%;
+    }
+    .review-panel-entry-time {
+      color: @content-secondary;
+    }
+    .review-panel-entry-actions {
+      display: flex;
+      align-items: center;
+      gap: @spacing-03;
+
+      .btn {
+        background-color: transparent;
+        padding: 0;
+        height: 24px;
+        width: 24px;
+
+        &:hover,
+        &:focus {
+          background-color: @neutral-20;
+          color: @content-primary;
+        }
+      }
+
+      .review-panel-entry-actions-icon {
+        padding: @spacing-01;
+        font-size: 20px;
+      }
+    }
+  }
+  .review-panel-change-body {
+    display: flex;
+    align-items: center;
+    color: @content-secondary;
+    gap: @spacing-02;
+  }
+  .review-panel-content-highlight {
+    color: @content-primary;
+    text-decoration: none;
+  }
+  del.review-panel-content-highlight {
+    text-decoration: line-through;
+  }
+
+  .review-panel-entry-icon {
+    border-radius: @border-radius-base-new;
+    padding: @spacing-02;
+    font-size: 16px;
+  }
+  .review-panel-entry-icon-accept {
+    background-color: @green-10;
+    color: @green-50;
+  }
+  .review-panel-entry-icon-reject {
+    background-color: @red-10;
+    color: @red-50;
+  }
+  .review-panel-entry-icon-changed {
+    background-color: @neutral-20;
+    color: @content-secondary;
+  }
+
+  .review-panel-header {
+    position: fixed;
+    top: 0;
+    z-index: 2;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    border-bottom: 1px solid @rp-border-grey;
+    background-color: white;
+    text-align: center;
+  }
+
+  // TODO: Update this when we move the track changes menu to the new design
+  .rp-tc-state {
+    background-color: white;
+  }
+
+  .review-panel-tools {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding-left: 4px;
+    padding-right: 12px;
+    flex-shrink: 0;
+    flex-basis: 32px;
+  }
+
+  .resolved-comments-toggle {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .track-changes-indicator-circle {
+    width: 8px;
+    height: 8px;
+    border-radius: 100%;
+    background-color: @green-50;
+  }
+
+  .track-changes-menu-button {
+    border: none;
+    background: none;
+    padding: 0;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    font-size: 14px;
+
+    i {
+      width: 8px;
+    }
+  }
+
+  .review-panel-heading {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 6px 4px;
+
+    .review-panel-label {
+      font-family: Lato, sans-serif;
+      font-size: 14px;
+      font-weight: bold;
+      margin: 0;
+    }
+
+    .review-panel-close-button {
+      display: flex;
+      align-items: center;
+      border: none;
+      background-color: transparent;
+      color: @content-primary;
+      padding: 2px;
+
+      &:hover,
+      &:focus {
+        background-color: @neutral-20;
+      }
+    }
+  }
+  .review-panel-comment-wrapper {
+    display: flex;
+    gap: @spacing-04;
+  }
+  .review-panel-comment {
+    flex-grow: 1;
+  }
+  .review-panel-comment-reply-divider {
+    border-left: 2px solid @yellow-20;
+  }
+  .review-panel-comment-body {
+    font-size: @font-size-02;
+    color: @content-primary;
+    overflow-wrap: anywhere;
+  }
+  .review-panel-content-expandable  {
+    display: -webkit-box;
+    text-overflow: ellipsis;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 3;
+    line-clamp: 3;
+    overflow: hidden;
+  }
+  .review-panel-content-expanded {
+    display: block;
+  }
+
+  .review-panel-comment-input {
+    width: 100%;
+    font-size: @rp-base-font-size;
+    padding: 2px @spacing-03;
+    border-radius: @border-radius-base-new;
+    border: solid 1px @neutral-60;
+    resize: vertical;
+    color: @rp-type-darkgrey;
+    background-color: #fff;
+    height: 25px;
+    min-height: 25px;
+    overflow-x: hidden;
+    max-height: 400px;
+  }
+
+  .review-panel-empty-state {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    pointer-events: none;
+  }
+
+  .review-panel-empty-state-inner {
+    position: sticky;
+    top: 50%;
+    transform: translateY(-50%);
+
+    width: 100%;
+    padding-left: 16px;
+    padding-right: 16px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+
+    p {
+      margin-bottom: 0;
+      text-align: center;
+    }
+  }
+
+  .review-panel-empty-state-comment-icon {
+    width: 80px;
+    height: 80px;
+    background-color: white;
+    border-radius: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-bottom: 16px;
+
+    .material-symbols {
+      font-size: 32px;
+    }
+  }
+
+  .review-panel-overview {
+    padding: 4px;
+    position: absolute;
+    top: 69px;
+    bottom: 30px;
+    width: 100%;
+    overflow: auto;
+    overscroll-behavior-block: none;
+
+    .review-panel-entry {
+      margin-left: 0;
+      width: 100%;
+    }
+  }
+
+  .review-panel-overview-file-header {
+    all: unset;
+    padding: 6px 8px;
+    font-size: 14px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    box-sizing: border-box;
+    width: 100%;
+  }
+
+  .review-panel-overfile-divider {
+    border-bottom: 1px solid #e7e9ee;
+    margin: 2px 0;
+  }
+
+  .review-panel-overview-file-entries {
+    overflow: hidden;
+    padding-top: 4px;
+    padding-bottom: 6px;
+  }
+
+  .review-panel-overview-file-entry-count {
+    background-color: @neutral-20;
+    padding: 2px 4px;
+    margin-left: auto;
+    border-radius: @border-radius-base;
+  }
+
+  .review-panel-footer {
+    z-index: 2;
+  }
+}
+
+.review-panel-new.review-panel-mini {
+  width: 22px !important;
+  overflow: visible !important;
+
+  .review-panel-entry {
+    margin-left: 2px;
+    background-color: transparent;
+    border: none;
+  }
+
+  .review-panel-entry-indicator {
+    position: absolute;
+    left: 0;
+    top: 7px;
+    display: flex;
+    width: 16px;
+    height: 16px;
+    color: @content-secondary;
+    cursor: pointer;
+  }
+
+  .review-panel-entry-content {
+    display: none;
+    background: white;
+    border: 1px solid @rp-border-grey;
+    border-radius: @border-radius-base-new;
+    width: 200px;
+    padding: @spacing-02;
+  }
+
+  .review-panel-entry:hover {
+    .review-panel-entry-content {
+      display: initial;
+      position: absolute;
+      left: -200px;
+      top: 0;
+    }
+  }
+}

+ 28 - 32
services/web/frontend/stylesheets/app/editor/review-panel.less

@@ -1134,44 +1134,40 @@ button when (@is-overleaf-light = true) {
   }
 }
 
-.rp-empty-state {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  left: 0;
-  right: 0;
-
-  .rp-empty-state-inner {
-    position: sticky;
-    top: 50%;
-    transform: translateY(-50%);
+.review-panel-indicator {
+  display: none;
+}
 
-    width: 100%;
-    padding-left: 16px;
-    padding-right: 16px;
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    flex-direction: column;
+.review-panel-mini {
+  width: 22px !important;
+  overflow: visible !important;
 
-    p {
-      margin-bottom: 0;
-      text-align: center;
-    }
+  .rp-entry {
+    height: 12px;
   }
 
-  .rp-empty-state-comment-icon {
-    width: 80px;
-    height: 80px;
-    background-color: white;
-    border-radius: 100%;
+  .rp-entry-indicator {
+    position: absolute;
+    left: -6px;
+    right: 0;
     display: flex;
-    align-items: center;
-    justify-content: center;
-    margin-bottom: 16px;
+    width: 20px;
+    height: 20px;
+  }
+
+  .rp-entry-content {
+    display: none;
+    background: white;
+    border: 1px solid @rp-border-grey;
+    width: 200px;
+  }
 
-    .material-symbols {
-      font-size: 32px;
+  .rp-entry:hover {
+    .rp-entry-content {
+      display: initial;
+      position: absolute;
+      left: -206px;
+      top: 0;
     }
   }
 }

+ 9 - 0
services/web/locales/en.json

@@ -32,6 +32,7 @@
   "accept": "Accept",
   "accept_all": "Accept all",
   "accept_and_continue": "Accept and continue",
+  "accept_change": "Accept change",
   "accept_invitation": "Accept invitation",
   "accept_or_reject_each_changes_individually": "Accept or reject each change individually",
   "accept_terms_and_conditions": "Accept terms and conditions",
@@ -415,6 +416,10 @@
   "delete_authentication_token": "Delete Authentication token",
   "delete_authentication_token_info": "You’re about to delete a Git authentication token. If you do, it can no longer be used to authenticate your identity when performing Git operations.",
   "delete_certificate": "Delete certificate",
+  "delete_comment": "Delete comment",
+  "delete_comment_message": "You cannot undo this action.",
+  "delete_comment_thread": "Delete comment thread",
+  "delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
   "delete_figure": "Delete figure",
   "delete_projects": "Delete Projects",
   "delete_row_or_column": "Delete row or column",
@@ -1212,6 +1217,7 @@
   "more": "More",
   "more_actions": "More actions",
   "more_info": "More Info",
+  "more_options": "More options",
   "more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
   "more_project_collaborators": "<0>More</0> project <0>collaborators</0>",
   "more_than_one_kind_of_snippet_was_requested": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.",
@@ -1600,6 +1606,7 @@
   "registration_error": "Registration error",
   "reject": "Reject",
   "reject_all": "Reject all",
+  "reject_change": "Reject change",
   "related_tags": "Related Tags",
   "relink_your_account": "Re-link your account",
   "reload_editor": "Reload editor",
@@ -1652,6 +1659,7 @@
   "reset_your_password": "Reset your password",
   "resize": "Resize",
   "resolve": "Resolve",
+  "resolve_comment": "Resolve comment",
   "resolved_comments": "Resolved comments",
   "restore": "Restore",
   "restore_file": "Restore file",
@@ -1802,6 +1810,7 @@
   "show_in_pdf": "Show in PDF",
   "show_less": "show less",
   "show_local_file_contents": "Show Local File Contents",
+  "show_more": "show more",
   "show_outline": "Show File outline",
   "show_x_more_projects": "Show __x__ more projects",
   "show_your_support": "Show your support",

+ 2 - 3
services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx

@@ -5,7 +5,6 @@ import { mockScope } from '../helpers/mock-scope'
 import { EditorProviders } from '../../../helpers/editor-providers'
 import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { activeEditorLine } from '../helpers/active-editor-line'
-import { User, UserId } from '../../../../../types/user'
 import { TestContainer } from '../helpers/test-container'
 import { FC } from 'react'
 import { MetadataContext } from '@/features/ide-react/context/metadata-context'
@@ -759,9 +758,9 @@ describe('autocomplete', { scrollBehavior: false }, function () {
 
     window.metaAttributesCache.set('ol-showSymbolPalette', true)
     const user = {
-      id: '123abd' as UserId,
+      id: '123abd',
       email: 'testuser@example.com',
-    } as User
+    }
     cy.mount(
       <TestContainer>
         <EditorProviders user={user} scope={scope}>

+ 1 - 1
services/web/types/user.ts

@@ -26,7 +26,7 @@ export type Features = {
 }
 
 export type User = {
-  id: UserId
+  id: UserId | null
   isAdmin?: boolean
   email: string
   allowedFreeTrial?: boolean