Przeglądaj źródła

Merge pull request #19985 from overleaf/dp-resolved-threads

Implement redesigned resolved threads popover

GitOrigin-RevId: 4e462eb26a2f2f3194fca89c39d5f9d08ea2e33c
David 2 lat temu
rodzic
commit
9416e69647

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

@@ -467,6 +467,7 @@
   "from_another_project": "",
   "from_enforcement_date": "",
   "from_external_url": "",
+  "from_filename": "",
   "from_project_files": "",
   "from_provider": "",
   "from_url": "",
@@ -808,6 +809,7 @@
   "managers_management": "",
   "managing_your_subscription": "",
   "mark_as_resolved": "",
+  "marked_as_resolved": "",
   "math_display": "",
   "math_inline": "",
   "maximum_files_uploaded_together": "",
@@ -885,6 +887,7 @@
   "no_pdf_error_title": "",
   "no_preview_available": "",
   "no_projects": "",
+  "no_resolved_comments": "",
   "no_resolved_threads": "",
   "no_search_results": "",
   "no_selection_select_file": "",
@@ -1050,6 +1053,7 @@
   "publishing": "",
   "pull_github_changes_into_sharelatex": "",
   "push_sharelatex_changes_to_github": "",
+  "quoted_text": "",
   "quoted_text_in": "",
   "raw_logs": "",
   "raw_logs_description": "",

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

@@ -0,0 +1,97 @@
+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 AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
+import ReviewPanelResolvedMessage from './review-panel-resolved-message'
+import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
+
+export const ReviewPanelCommentContent = memo<{
+  comment: Change<CommentOperation>
+  isResolved: boolean
+}>(({ comment, isResolved }) => {
+  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 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) {
+    return null
+  }
+
+  return (
+    <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)}
+              isThreadResolved={isResolved}
+            />
+          </div>
+        )
+      })}
+
+      {isResolved && (
+        <div className="review-panel-comment-wrapper">
+          <div className="review-panel-comment-reply-divider" />
+          <ReviewPanelResolvedMessage
+            thread={thread as ReviewPanelResolvedCommentThread}
+          />
+        </div>
+      )}
+
+      {!isResolved && (
+        <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>
+  )
+})
+ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'

+ 5 - 71
services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx

@@ -1,49 +1,16 @@
-import { memo, useCallback, useState } from 'react'
+import { memo } 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 { useThreadsContext } from '../context/threads-context'
 import classnames from 'classnames'
-import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
-import MaterialIcon from '@/shared/components/material-icon'
 import { ReviewPanelEntry } from './review-panel-entry'
+import MaterialIcon from '@/shared/components/material-icon'
+import { ReviewPanelCommentContent } from './review-panel-comment-content'
 
 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 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) {
@@ -62,40 +29,7 @@ export const ReviewPanelComment = memo<{
       <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>
+      <ReviewPanelCommentContent comment={comment} isResolved={false} />
     </ReviewPanelEntry>
   )
 })

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

@@ -1,5 +1,5 @@
 import { FC, memo, useState } from 'react'
-import { ReviewPanelResolvedThreads } from './review-panel-resolved-threads'
+import { ReviewPanelResolvedThreadsButton } from './review-panel-resolved-threads-button'
 import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
 import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
 import { Button } from 'react-bootstrap'
@@ -28,7 +28,7 @@ const ReviewPanelHeader: FC<{
         </Button>
       </div>
       <div className="review-panel-tools">
-        <ReviewPanelResolvedThreads />
+        <ReviewPanelResolvedThreadsButton />
         <ReviewPanelTrackChangesMenuButton
           menuExpanded={trackChangesMenuExpanded}
           setMenuExpanded={setTrackChangesMenuExpanded}

+ 16 - 6
services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx

@@ -21,7 +21,15 @@ export const ReviewPanelMessage: FC<{
   hasReplies: boolean
   isReply: boolean
   onResolve: () => void
-}> = ({ message, threadId, isReply, hasReplies, onResolve }) => {
+  isThreadResolved: boolean
+}> = ({
+  message,
+  threadId,
+  isReply,
+  hasReplies,
+  onResolve,
+  isThreadResolved,
+}) => {
   const { t } = useTranslation()
   const [editing, setEditing] = useState(false)
   const [deleting, setDeleting] = useState(false)
@@ -93,7 +101,7 @@ export const ReviewPanelMessage: FC<{
         </div>
 
         <div className="review-panel-entry-actions">
-          {!isReply && (
+          {!isReply && !isThreadResolved && (
             <Tooltip
               id="resolve-thread"
               overlayProps={{ placement: 'bottom' }}
@@ -109,10 +117,12 @@ export const ReviewPanelMessage: FC<{
             </Tooltip>
           )}
 
-          <ReviewPanelCommentOptions
-            onEdit={handleEditOption}
-            onDelete={showDeleteModal}
-          />
+          {!isThreadResolved && (
+            <ReviewPanelCommentOptions
+              onEdit={handleEditOption}
+              onDelete={showDeleteModal}
+            />
+          )}
         </div>
       </div>
       <ExpandableContent className="review-panel-comment-body">

+ 3 - 25
services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx

@@ -1,39 +1,17 @@
-import { FC, useEffect, useMemo, useState } from 'react'
+import { FC, useMemo } 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'
+import useProjectRanges from '../hooks/use-project-ranges'
 
 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 { projectRanges, error } = useProjectRanges()
 
   const rangesForDocs = useMemo(() => {
     if (docs && docRanges && projectRanges) {

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

@@ -0,0 +1,31 @@
+import { FC } from 'react'
+import { formatTimeBasedOnYear } from '@/features/utils/format-date'
+import { buildName } from '../utils/build-name'
+import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
+import { useTranslation } from 'react-i18next'
+
+const ReviewPanelResolvedMessage: FC<{
+  thread: ReviewPanelResolvedCommentThread
+}> = ({ thread }) => {
+  const { t } = useTranslation()
+
+  return (
+    <div className="review-panel-comment">
+      <div className="review-panel-entry-header">
+        <div>
+          <div className="review-panel-entry-user">
+            {buildName(thread.resolved_by_user)}
+          </div>
+          <div className="review-panel-entry-time">
+            {formatTimeBasedOnYear(thread.resolved_at)}
+          </div>
+        </div>
+      </div>
+      <div className="review-panel-comment-body review-panel-resolved-message">
+        <i>{t('marked_as_resolved')}</i>
+      </div>
+    </div>
+  )
+}
+
+export default ReviewPanelResolvedMessage

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

@@ -0,0 +1,55 @@
+import React, { FC } from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import { ThreadId } from '../../../../../types/review-panel/review-panel'
+import { useThreadsActionsContext } from '../context/threads-context'
+import { Button } from 'react-bootstrap'
+import MaterialIcon from '@/shared/components/material-icon'
+import { ExpandableContent } from './review-panel-expandable-content'
+import { ReviewPanelCommentContent } from './review-panel-comment-content'
+import { Change, CommentOperation } from '../../../../../types/change'
+
+export const ReviewPanelResolvedThread: FC<{
+  id: string
+  comment: Change<CommentOperation>
+  docName: string
+}> = ({ id, comment, docName }) => {
+  const { t } = useTranslation()
+  const { reopenThread, deleteThread } = useThreadsActionsContext()
+
+  return (
+    <div className="review-panel-resolved-comment" key={id}>
+      <div className="review-panel-resolved-comment-header">
+        <div>
+          <Trans
+            i18nKey="from_filename"
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <span className="review-panel-resolved-comment-filename" />,
+            ]}
+            values={{ filename: docName }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+          />
+        </div>
+        <div className="review-panel-resolved-comment-buttons">
+          <Button onClick={() => reopenThread(id as ThreadId)}>
+            <MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
+          </Button>
+          <Button onClick={() => deleteThread(id as ThreadId)}>
+            <MaterialIcon type="delete" accessibilityLabel={t('delete')} />
+          </Button>
+        </div>
+      </div>
+      <div className="review-panel-resolved-comment-quoted-text">
+        <div className="review-panel-resolved-comment-quoted-text-label">
+          {t('quoted_text')}
+        </div>
+        <ExpandableContent className="review-panel-resolved-comment-quoted-text-quote">
+          {comment?.op.c}
+        </ExpandableContent>
+      </div>
+
+      <ReviewPanelCommentContent comment={comment} isResolved />
+    </div>
+  )
+}

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

@@ -0,0 +1,40 @@
+import React, { FC, useRef, useState } from 'react'
+import Icon from '@/shared/components/icon'
+import { ReviewPanelResolvedThreadsMenu } from './review-panel-resolved-threads-menu'
+import { Overlay, Popover } from 'react-bootstrap'
+
+export const ReviewPanelResolvedThreadsButton: FC = () => {
+  const [expanded, setExpanded] = useState(false)
+  const buttonRef = useRef<HTMLButtonElement>(null)
+
+  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={this}
+          containerPadding={0}
+          placement="bottom"
+          rootClose
+          target={buttonRef.current ?? undefined}
+        >
+          <Popover
+            id="popover-resolved-threads"
+            className="review-panel-resolved-comments review-panel-new"
+          >
+            <ReviewPanelResolvedThreadsMenu />
+          </Popover>
+        </Overlay>
+      )}
+    </>
+  )
+}

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

@@ -0,0 +1,103 @@
+import React, { FC, useMemo } from 'react'
+import { useThreadsContext } from '../context/threads-context'
+import { useTranslation } from 'react-i18next'
+import { ReviewPanelResolvedThread } from './review-panel-resolved-thread'
+import useProjectRanges from '../hooks/use-project-ranges'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import Icon from '@/shared/components/icon'
+import { Change, CommentOperation } from '../../../../../types/change'
+
+export const ReviewPanelResolvedThreadsMenu: FC = () => {
+  const { t } = useTranslation()
+  const threads = useThreadsContext()
+  const { docs } = useFileTreeData()
+
+  const { projectRanges, loading } = useProjectRanges()
+
+  const docNameForThread = useMemo(() => {
+    const docNameForThread = new Map<string, string>()
+
+    for (const [docId, ranges] of projectRanges?.entries() ?? []) {
+      const docName = docs?.find(doc => doc.doc.id === docId)?.doc.name
+      if (docName !== undefined) {
+        for (const comment of ranges.comments) {
+          const threadId = comment.op.t
+          docNameForThread.set(threadId, docName)
+        }
+      }
+    }
+
+    return docNameForThread
+  }, [docs, projectRanges])
+
+  const allComments = useMemo(() => {
+    const allComments = new Map<string, Change<CommentOperation>>()
+
+    // eslint-disable-next-line no-unused-vars
+    for (const [_, ranges] of projectRanges?.entries() ?? []) {
+      for (const comment of ranges.comments) {
+        allComments.set(comment.op.t, comment)
+      }
+    }
+
+    return allComments
+  }, [projectRanges])
+
+  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])
+
+  if (loading) {
+    return (
+      <div className="review-panel-resolved-comments-loading">
+        <Icon type="spinner" spin />
+      </div>
+    )
+  }
+
+  if (!resolvedThreads.length) {
+    return (
+      <div className="review-panel-resolved-comments-empty">
+        {t('no_resolved_comments')}
+      </div>
+    )
+  }
+
+  return (
+    <>
+      <div className="review-panel-resolved-comments-header">
+        <div className="review-panel-resolved-comments-label">
+          {t('resolved_comments')}
+        </div>
+        <div className="review-panel-resolved-comments-count">
+          {resolvedThreads.length}
+        </div>
+      </div>
+      {resolvedThreads.map(thread => {
+        const comment = allComments.get(thread.id)
+        if (!comment) {
+          return null
+        }
+
+        return (
+          <ReviewPanelResolvedThread
+            key={thread.id}
+            id={thread.id}
+            comment={comment}
+            docName={docNameForThread.get(thread.id) ?? t('unknown')}
+          />
+        )
+      })}
+    </>
+  )
+}

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

@@ -1,87 +0,0 @@
-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>
-      )}
-    </>
-  )
-}

+ 35 - 0
services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts

@@ -0,0 +1,35 @@
+import { useEffect, useState } from 'react'
+import { Ranges } from '../context/ranges-context'
+import { useProjectContext } from '@/shared/context/project-context'
+import { getJSON } from '@/infrastructure/fetch-json'
+
+export default function useProjectRanges() {
+  const { _id: projectId } = useProjectContext()
+  const [error, setError] = useState<Error>()
+  const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    setLoading(true)
+    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))
+      .finally(() => setLoading(false))
+  }, [projectId])
+
+  return { projectRanges, error, loading }
+}

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

@@ -186,6 +186,107 @@
       }
     }
   }
+
+  &.review-panel-resolved-comments {
+    width: 280px;
+
+    .popover-content {
+      background-color: @neutral-10;
+      overflow-y: auto;
+      max-height: calc(100vh - 180px);
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
+      padding: 8px 6px;
+    }
+  }
+
+  .review-panel-resolved-comments-loading {
+    text-align: center;
+  }
+
+  .review-panel-resolved-comments-empty {
+    text-align: center;
+  }
+
+  .review-panel-resolved-comments-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 4px 0;
+  }
+
+  .review-panel-resolved-comments-label {
+    font-weight: bold;
+    font-size: 14px;
+  }
+
+  .review-panel-resolved-comments-count {
+    background-color: @neutral-20;
+    border-radius: @border-radius-base-new;
+    padding: 2px;
+  }
+
+  .review-panel-resolved-comment {
+    background-color: white;
+    border-radius: @border-radius-base-new;
+    padding: @spacing-04;
+    display: flex;
+    flex-direction: column;
+    gap: @spacing-04;
+  }
+
+  .review-panel-resolved-comment-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: @content-secondary;
+    font-size: 12px;
+  }
+
+  .review-panel-resolved-comment-filename {
+    color: @content-primary;
+  }
+
+  .review-panel-resolved-comment-buttons {
+    display: flex;
+    align-items: center;
+    gap: @spacing-03;
+
+    .btn {
+      background-color: transparent;
+      color: @content-primary;
+      padding: @spacing-01;
+      height: 24px;
+      width: 24px;
+
+      &:hover,
+      &:focus {
+        background-color: @neutral-20;
+      }
+    }
+
+    .material-symbols {
+      font-size: 20px;
+    }
+  }
+
+  .review-panel-resolved-comment-quoted-text {
+    background-color: @neutral-20;
+    border-radius: @border-radius-base-new;
+    padding: 4px 8px;
+  }
+
+  .review-panel-resolved-comment-quoted-text-label {
+    color: @content-secondary;
+    font-size: 12px;
+  }
+
+  .review-panel-resolved-comment-quoted-text-quote {
+    overflow-wrap: anywhere;
+    font-size: 14px;
+  }
+
   .review-panel-comment-wrapper {
     display: flex;
     gap: @spacing-04;
@@ -201,6 +302,7 @@
     color: @content-primary;
     overflow-wrap: anywhere;
   }
+
   .review-panel-content-expandable  {
     display: -webkit-box;
     text-overflow: ellipsis;

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

@@ -703,6 +703,7 @@
   "from_another_project": "From another project",
   "from_enforcement_date": "From __enforcementDate__ any additional editors on this project will be made viewers.",
   "from_external_url": "From external URL",
+  "from_filename": "From <0>__filename__</0>",
   "from_project_files": "From project files",
   "from_provider": "From __provider__",
   "from_url": "From URL",
@@ -1187,6 +1188,7 @@
   "managing_your_subscription": "Managing your subscription",
   "march": "March",
   "mark_as_resolved": "Mark as resolved",
+  "marked_as_resolved": "Marked as resolved",
   "math_display": "Math Display",
   "math_inline": "Math Inline",
   "max_collab_per_project": "Max. collaborators per project",
@@ -1298,6 +1300,7 @@
   "no_planned_maintenance": "There is currently no planned maintenance",
   "no_preview_available": "Sorry, no preview is available.",
   "no_projects": "No projects",
+  "no_resolved_comments": "No resolved comments",
   "no_resolved_threads": "No resolved threads",
   "no_search_results": "No Search Results",
   "no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.",
@@ -1549,6 +1552,7 @@
   "purchase_now": "Purchase Now",
   "purchase_now_lowercase": "Purchase now",
   "push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
+  "quoted_text": "Quoted text",
   "quoted_text_in": "Quoted text in",
   "raw_logs": "Raw logs",
   "raw_logs_description": "Raw logs from the LaTeX compiler",