Conversation
약속 회고 생성 페이지에서 AI 요약 시작 시 결과 페이지로 이동하여 로딩 오버레이 → 스켈레톤 → 완료 토스트 플로우를 표시하는 UI 구현. 실제 API 연동은 백엔드 확정 후 교체 예정 (현재 setTimeout mock). - 약속 회고 생성/결과 페이지 및 라우트 추가 - AI 로딩 오버레이(AiLoadingOverlay) + 완료 토스트(AiSummaryToast) - 피그마 디자인 기반 그래디언트 AI 아이콘 및 펄스 애니메이션 - 회고 스켈레톤(RetrospectiveSummarySkeleton) 컴포넌트 - sonner 토스트 라이브러리 및 Sonner 컴포넌트 연동 - 약속 상세 → 회고 네비게이션 연결
Walkthrough새로운 회고 기능을 위해 AI 요약 UI 컴포넌트(아이콘, 로딩 오버레이, 토스트), 회고 페이지 및 생성 페이지, 관련 라우팅을 추가하고 미팅 상세 페이지에서 진입점을 연결했습니다. Changes
Sequence DiagramsequenceDiagram
participant User
participant MeetingDetailPage
participant MeetingRetrospectivePage
participant MeetingRetrospectiveCreatePage
participant AiLoadingOverlay
participant AiSummaryToast
User->>MeetingDetailPage: 미팅 상세 페이지 진입 (POST 상태)
MeetingDetailPage->>User: RetrospectiveCardButtons 표시
User->>MeetingRetrospectiveCreatePage: "약속 회고" 클릭
MeetingRetrospectiveCreatePage->>MeetingRetrospectivePage: AI 요약 시작 (fromAiSummary=true)
MeetingRetrospectivePage->>AiLoadingOverlay: AI 요약 중 오버레이 표시
Note over AiLoadingOverlay: 3초 대기
AiLoadingOverlay->>AiLoadingOverlay: 로딩 완료
MeetingRetrospectivePage->>AiSummaryToast: 완료 토스트 표시
AiSummaryToast->>User: 완료 메시지 (3초 표시 후 사라짐)
추정 리뷰 노력🎯 3 (중간) | ⏱️ ~20분 이유: 새로운 컴포넌트/페이지 5개 추가로 분산된 변경이 많지만, 각각 단순하고 독립적 구조입니다. AI 요약 흐름에서 상태 관리 로직(타이머, 모달 제어)이 있어 검토 주의 필요합니다. 가능한 관련 이슈
가능한 관련 PR
제안 리뷰어
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (6)
src/features/retrospectives/components/AiGradientIcon.tsx (1)
9-19: 하드코딩된 gradientid="ai-gradient"— 충돌 가능성 낮지만 참고.동일 페이지에 다른 SVG가 같은 id를 사용하면 그라디언트가 예기치 않게 덮어씌워질 수 있습니다. 현재 사용 범위에서는 문제없지만, 재사용 범위가 넓어지면
useId()로 고유 id 생성을 고려해주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/retrospectives/components/AiGradientIcon.tsx` around lines 9 - 19, The hardcoded gradient id "ai-gradient" can collide with other SVGs; in the AiGradientIcon component generate a unique id using React's useId() (import useId from 'react'), e.g. const gradientId = `${useId()}-ai-gradient`, then replace the linearGradient id and any fill/url references (e.g. fill="url(`#ai-gradient`)") to use this gradientId so the gradient is unique per-instance and avoids cross-SVG collisions.src/shared/ui/Sonner.tsx (1)
10-10:icons: { error: ' ' }— 공백 문자열로 아이콘 숨김.에러 아이콘을 공백 문자열로 대체하면 DOM에 빈 텍스트 노드가 남습니다.
false또는 빈 fragment를 사용하면 더 깔끔합니다.♻️ 제안
- icons={{ error: ' ' }} + icons={{ error: <></> }}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/Sonner.tsx` at line 10, Replace the placeholder space string used to hide the error icon (icons: { error: ' ' }) in Sonner.tsx with a cleaner falsy/iconless value; update the icons prop (icons: { error: false } or icons: { error: () => null } or an empty fragment) so the DOM does not render an empty text node — locate the icons prop usage in Sonner.tsx and swap the ' ' value for false or a null-returning component.src/features/retrospectives/components/AiSummaryToast.tsx (1)
1-67: Sonner 토스트 라이브러리와 커스텀 토스트 컴포넌트가 공존.이 PR에서 Sonner(
src/shared/lib/toast.ts,src/shared/ui/Sonner.tsx)를 전역 토스트로 도입하면서, 별도의 포탈 기반 커스텀 토스트(AiSummaryToast)도 추가했습니다. 디자인 요구사항(중앙 배치, 체크 아이콘 등)이 Sonner의 커스터마이징 범위를 벗어나는 것이라면 괜찮지만, 향후 토스트 관리 포인트가 이원화될 수 있으니 팀 내 합의를 확인해주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/retrospectives/components/AiSummaryToast.tsx` around lines 1 - 67, There are now two toast systems (the custom AiSummaryToast component and the Sonner-based global toast via toast/Sonner) which will split toast management; pick one and consolidate: either adapt Sonner (Sonner component and exported toast API) to support the centered check-icon design and durations used by AiSummaryToast, then replace usages of AiSummaryToast with toast calls (ensuring you map duration and onDismiss behavior), or migrate AiSummaryToast to use the Sonner API internally (wrap Sonner/toast to render the centered portal design) and remove the standalone portal usage; update all places that import AiSummaryToast or call the global toast to use the chosen unified API and ensure dismissal, timing (duration), and accessibility are preserved.src/pages/Retrospectives/MeetingRetrospectivePage.tsx (2)
16-21:useParamsnon-null assertion — 첫 번째 파일과 동일한 문제
gatheringId!,meetingId!가 Line 50, 62에서 사용됩니다. 위 파일과 동일하게 early return guard를 권장합니다.추가로 Line 21의
location.state as { fromAiSummary?: boolean }는 동작하지만, 향후 state 필드가 늘어날 경우 별도 타입을 정의하면 유지보수가 편해집니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/Retrospectives/MeetingRetrospectivePage.tsx` around lines 16 - 21, The component reads gatheringId and meetingId from useParams and later uses non-null assertions (gatheringId!, meetingId!), so add an early-return guard at the start of MeetingRetrospectivePage that checks both params and returns a fallback (null or an error/redirect UI) when they are missing; replace the non-null assertions with the guarded variables. Also replace the inline cast for location.state with a dedicated type (e.g., MeetingRetrospectiveLocationState) and use that type for the location/state access (and default fromAiSummary to false), so future additions to state remain type-safe; update references like gatheringId, meetingId, and fromAiSummary accordingly.
79-79:AiLoadingOverlay에onCancel미전달 — 사용자가 AI 요약을 취소할 수 없음
AiLoadingOverlay컴포넌트는onCancelprop이 있을 때 취소 버튼을 렌더링합니다(src/features/retrospectives/components/AiLoadingOverlay.tsxLine 26 참조). 현재onCancel을 넘기지 않아 3초 동안 사용자가 아무 조작도 할 수 없습니다.실제 API 연동 시 요청 시간이 길어질 수 있으므로, 취소 핸들러를 전달하는 것을 고려해 주세요.
💡 제안
-<AiLoadingOverlay isOpen={showOverlay} /> +<AiLoadingOverlay + isOpen={showOverlay} + onCancel={() => { + setShowOverlay(false) + setIsLoading(false) + }} +/>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/Retrospectives/MeetingRetrospectivePage.tsx` at line 79, AiLoadingOverlay is rendered without an onCancel handler so users cannot cancel the AI summary (the component renders a cancel button only when onCancel is provided). Fix by wiring an onCancel callback into the AiLoadingOverlay instance (e.g., pass onCancel={handleCancelAiSummary}) that (1) updates local state to hide the overlay (call setShowOverlay(false) or equivalent) and (2) aborts the in-flight AI request if applicable (use your AbortController or cancellation mechanism used by the API call). Ensure handleCancelAiSummary is defined near the summary-starting logic and referenced here so the overlay can be dismissed immediately.src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx (1)
9-18:useParams값에 non-null assertion(!) 사용 — undefined 가능성 존재
useParams는string | undefined를 반환합니다. URL이 예상과 다르게 매칭되면gatheringId!,meetingId!가undefined인 채로ROUTES.MEETING_RETROSPECTIVE(undefined, undefined)가 호출되어 잘못된 경로(/gatherings/undefined/meetings/undefined/retrospective)로 이동합니다.early return guard를 추가하거나, 값이 없을 때 에러 UI를 보여주는 방식을 권장합니다.
🛡️ 제안
const { gatheringId, meetingId } = useParams<{ gatheringId: string meetingId: string }>() +if (!gatheringId || !meetingId) { + return <Navigate to={ROUTES.HOME} replace /> +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx` around lines 9 - 18, The use of non-null assertions on useParams (gatheringId!, meetingId!) is unsafe; update MeetingRetrospectiveCreatePage to guard against undefined params by checking gatheringId and meetingId after calling useParams and rendering an early return or error/placeholder UI when either is missing, and only call ROUTES.MEETING_RETROSPECTIVE(gatheringId, meetingId) and render SubPageHeader when both values are present; reference the useParams call, the gatheringId and meetingId variables, the SubPageHeader component, and ROUTES.MEETING_RETROSPECTIVE to locate and patch the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/features/retrospectives/components/AiLoadingOverlay.tsx`:
- Line 3: Replace the relative import of Button in AiLoadingOverlay.tsx with the
project alias: update the import statement that currently references
'../../../shared/ui/Button' to use the alias '@/shared/ui/Button' so it matches
other files; ensure the change is applied to the import at the top of the
AiLoadingOverlay component where Button is imported.
In `@src/features/retrospectives/components/AiSummaryToast.tsx`:
- Around line 46-47: The effect in AiSummaryToast.tsx that depends on
[isVisible, duration] omits onDismiss, causing a stale-closure bug; either
stabilize the onDismiss prop with useCallback in the parent or inside
AiSummaryToast wrap it with a ref (latest pattern) and call ref.current in the
useEffect/timeout, or include onDismiss in the dependency array of the useEffect
so the timeout clears/restarts when onDismiss changes; update the component to
reference the stabilized onDismiss (useCallback or useRef) instead of relying on
an eslint-disable comment.
- Around line 37-45: The nested setTimeout(onDismiss, 300) inside the
dismissTimer closure isn’t cleared on unmount; update AiSummaryToast to track
that second timeout with a ref (e.g., create a dismissCallbackRef via useRef)
and store the inner timeout id there, add useRef to imports, and
clearTimeout(dismissCallbackRef.current) in the cleanup along with
cancelAnimationFrame(fadeInTimer) and clearTimeout(dismissTimer) so the
onDismiss callback can never run after unmount.
In `@src/features/retrospectives/components/RetrospectiveCardButtons.tsx`:
- Around line 1-55: The file's formatting in component RetrospectiveCardButtons
(export default function RetrospectiveCardButtons) is causing CI Prettier
warnings; run prettier --write against this file or the repo (e.g., prettier
--write src/features/retrospectives/components/RetrospectiveCardButtons.tsx),
stage the updated file, and commit the changes so the Prettier formatting issues
are resolved before merging.
In `@src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx`:
- Line 22: Prettier formatting warning: run the formatter (e.g., run `prettier
--write` or your project’s format script) and re-commit the changes to fix the
formatting on the JSX line that defines the sticky header (the div with
className "sticky top-[calc(var(--gnb-height)+59px)] z-30 flex items-center
justify-between bg-white pb-small" in MeetingRetrospectiveCreatePage.tsx);
ensure the file is saved and the updated formatted file is included in the PR.
---
Nitpick comments:
In `@src/features/retrospectives/components/AiGradientIcon.tsx`:
- Around line 9-19: The hardcoded gradient id "ai-gradient" can collide with
other SVGs; in the AiGradientIcon component generate a unique id using React's
useId() (import useId from 'react'), e.g. const gradientId =
`${useId()}-ai-gradient`, then replace the linearGradient id and any fill/url
references (e.g. fill="url(`#ai-gradient`)") to use this gradientId so the
gradient is unique per-instance and avoids cross-SVG collisions.
In `@src/features/retrospectives/components/AiSummaryToast.tsx`:
- Around line 1-67: There are now two toast systems (the custom AiSummaryToast
component and the Sonner-based global toast via toast/Sonner) which will split
toast management; pick one and consolidate: either adapt Sonner (Sonner
component and exported toast API) to support the centered check-icon design and
durations used by AiSummaryToast, then replace usages of AiSummaryToast with
toast calls (ensuring you map duration and onDismiss behavior), or migrate
AiSummaryToast to use the Sonner API internally (wrap Sonner/toast to render the
centered portal design) and remove the standalone portal usage; update all
places that import AiSummaryToast or call the global toast to use the chosen
unified API and ensure dismissal, timing (duration), and accessibility are
preserved.
In `@src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx`:
- Around line 9-18: The use of non-null assertions on useParams (gatheringId!,
meetingId!) is unsafe; update MeetingRetrospectiveCreatePage to guard against
undefined params by checking gatheringId and meetingId after calling useParams
and rendering an early return or error/placeholder UI when either is missing,
and only call ROUTES.MEETING_RETROSPECTIVE(gatheringId, meetingId) and render
SubPageHeader when both values are present; reference the useParams call, the
gatheringId and meetingId variables, the SubPageHeader component, and
ROUTES.MEETING_RETROSPECTIVE to locate and patch the code.
In `@src/pages/Retrospectives/MeetingRetrospectivePage.tsx`:
- Around line 16-21: The component reads gatheringId and meetingId from
useParams and later uses non-null assertions (gatheringId!, meetingId!), so add
an early-return guard at the start of MeetingRetrospectivePage that checks both
params and returns a fallback (null or an error/redirect UI) when they are
missing; replace the non-null assertions with the guarded variables. Also
replace the inline cast for location.state with a dedicated type (e.g.,
MeetingRetrospectiveLocationState) and use that type for the location/state
access (and default fromAiSummary to false), so future additions to state remain
type-safe; update references like gatheringId, meetingId, and fromAiSummary
accordingly.
- Line 79: AiLoadingOverlay is rendered without an onCancel handler so users
cannot cancel the AI summary (the component renders a cancel button only when
onCancel is provided). Fix by wiring an onCancel callback into the
AiLoadingOverlay instance (e.g., pass onCancel={handleCancelAiSummary}) that (1)
updates local state to hide the overlay (call setShowOverlay(false) or
equivalent) and (2) aborts the in-flight AI request if applicable (use your
AbortController or cancellation mechanism used by the API call). Ensure
handleCancelAiSummary is defined near the summary-starting logic and referenced
here so the overlay can be dismissed immediately.
In `@src/shared/ui/Sonner.tsx`:
- Line 10: Replace the placeholder space string used to hide the error icon
(icons: { error: ' ' }) in Sonner.tsx with a cleaner falsy/iconless value;
update the icons prop (icons: { error: false } or icons: { error: () => null }
or an empty fragment) so the DOM does not render an empty text node — locate the
icons prop usage in Sonner.tsx and swap the ' ' value for false or a
null-returning component.
- AiLoadingOverlay: 상대 경로 → @/ 별칭 import 수정 - AiSummaryToast: 내부 setTimeout 미정리 수정 (useRef로 cleanup) - AiSummaryToast: onDismiss stale closure 수정 (useRef 패턴)
- AiGradientIcon: useId()로 gradient id 유니크 생성 - CreatePage/Page: useParams guard, non-null assertion 제거 - Page: location.state 타입 정의, onCancel 핸들러 연결 - Sonner: error icon 빈 문자열을 빈 Fragment로 교체
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx (1)
21-21:59px하드코딩 — SubPageHeader 높이 변경 시 오정렬 위험
SubPageHeader높이가 외부에서 CSS 변수로 노출되지 않아59px를 직접 기입했지만, 추후 헤더 패딩·폰트 크기 조정 시 sticky 위치가 어긋날 수 있습니다.♻️ 개선 제안: CSS 변수로 분리
globals.css등에서 SubPageHeader 높이를 변수화하거나, 페이지 전반에서 동일한 오프셋을 쓰는 경우에는 공유 토큰으로 추출하는 것이 유지보수에 유리합니다.- <div className="sticky top-[calc(var(--gnb-height)+59px)] z-30 flex items-center justify-between bg-white pb-small"> + <div className="sticky top-[calc(var(--gnb-height)+var(--sub-header-height))] z-30 flex items-center justify-between bg-white pb-small">
globals.css::root { + --sub-header-height: 59px; /* SubPageHeader 실측 높이 */ }단기적으로 즉시 수정이 어렵다면 인라인 주석으로라도 의도를 명시해두는 것을 권장합니다.
- <div className="sticky top-[calc(var(--gnb-height)+59px)] z-30 ..."> + {/* 59px = SubPageHeader (py-small × 2 + medium TextButton 높이) */} + <div className="sticky top-[calc(var(--gnb-height)+59px)] z-30 ...">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx` at line 21, Replace the hard-coded "59px" offset in the sticky container (the div with className "sticky top-[calc(var(--gnb-height)+59px)] z-30 flex items-center justify-between bg-white pb-small") with a CSS variable (e.g. var(--sub-header-height)); add that variable to your global stylesheet (globals.css or shared token) with the measured SubPageHeader height, or if you cannot change globals immediately, add an inline comment next to the top calc noting that 59px equals SubPageHeader height and TODO to replace with --sub-header-height so future header height changes won’t break the sticky offset.src/pages/Retrospectives/MeetingRetrospectivePage.tsx (1)
65-65:59px매직 넘버 — CSS 변수 또는 디자인 토큰 사용 고려문제:
59px는SubPageHeader의 높이를 하드코딩한 값입니다.영향:
SubPageHeader의 레이아웃(패딩, 폰트 크기 등)이 변경되면 이 계산식이 조용히 깨집니다.대안: CSS 변수를 정의하거나 Tailwind 디자인 토큰으로 추출하는 것이 유지보수에 유리합니다.
♻️ CSS 변수 활용 예시
globals.css등에 변수를 추가한 뒤:/* e.g., in globals.css */ :root { --sub-page-header-height: 59px; }사용:
- className="sticky top-[calc(var(--gnb-height)+59px)] z-30 ..." + className="sticky top-[calc(var(--gnb-height)+var(--sub-page-header-height))] z-30 ..."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/Retrospectives/MeetingRetrospectivePage.tsx` at line 65, Replace the hardcoded 59px magic number used in the sticky top calculation in the div with class "sticky top-[calc(var(--gnb-height)+59px)]" by introducing and using a CSS variable or Tailwind design token (e.g., --sub-page-header-height) so the layout ties to SubPageHeader's actual height; update the global CSS (or design token config) to define the variable and change the top calc to use var(--sub-page-header-height) in MeetingRetrospectivePage.tsx so future header height changes remain consistent with the sticky offset.src/features/retrospectives/components/AiSummaryToast.tsx (1)
52-56:cn헬퍼로 통일 (선택 사항)배열
.join(' ')방식은 동작하지만, 코드베이스의 다른 컴포넌트들이cn을 사용하는 패턴과 불일치합니다.♻️ `cn` 헬퍼로 교체 제안
+import { cn } from '@/shared/lib/utils' ... <div - className={[ - 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2', - 'transition-opacity duration-300', - opacity ? 'opacity-100' : 'opacity-0', - ].join(' ')} + className={cn( + 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2', + 'transition-opacity duration-300', + opacity ? 'opacity-100' : 'opacity-0', + )} >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/retrospectives/components/AiSummaryToast.tsx` around lines 52 - 56, Replace the array.join(' ') className construction in the AiSummaryToast component with the shared cn helper to match project patterns; locate the className prop in AiSummaryToast.tsx (the JSX that builds 'fixed left-1/2 top-1/2 z-50 ... transition-opacity ... opacity-100/opacity-0') and wrap those class strings/conditional expression with cn(...) instead of using [.join(' ')], preserving the conditional opacity logic and imports (add or reuse the cn import if not already present).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/features/retrospectives/components/AiSummaryToast.tsx`:
- Around line 52-56: Replace the array.join(' ') className construction in the
AiSummaryToast component with the shared cn helper to match project patterns;
locate the className prop in AiSummaryToast.tsx (the JSX that builds 'fixed
left-1/2 top-1/2 z-50 ... transition-opacity ... opacity-100/opacity-0') and
wrap those class strings/conditional expression with cn(...) instead of using
[.join(' ')], preserving the conditional opacity logic and imports (add or reuse
the cn import if not already present).
In `@src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx`:
- Line 21: Replace the hard-coded "59px" offset in the sticky container (the div
with className "sticky top-[calc(var(--gnb-height)+59px)] z-30 flex items-center
justify-between bg-white pb-small") with a CSS variable (e.g.
var(--sub-header-height)); add that variable to your global stylesheet
(globals.css or shared token) with the measured SubPageHeader height, or if you
cannot change globals immediately, add an inline comment next to the top calc
noting that 59px equals SubPageHeader height and TODO to replace with
--sub-header-height so future header height changes won’t break the sticky
offset.
In `@src/pages/Retrospectives/MeetingRetrospectivePage.tsx`:
- Line 65: Replace the hardcoded 59px magic number used in the sticky top
calculation in the div with class "sticky top-[calc(var(--gnb-height)+59px)]" by
introducing and using a CSS variable or Tailwind design token (e.g.,
--sub-page-header-height) so the layout ties to SubPageHeader's actual height;
update the global CSS (or design token config) to define the variable and change
the top calc to use var(--sub-page-header-height) in
MeetingRetrospectivePage.tsx so future header height changes remain consistent
with the sticky offset.
🚀 풀 리퀘스트 제안
📋 작업 내용
약속 회고 생성 페이지에서 "AI 요약 시작하기" 클릭 시 결과 페이지로 이동하여
로딩 오버레이 → 스켈레톤 → 완료 토스트 순서로 AI 요약 플로우 UI를 표시합니다.
실제 API 연동은 백엔드 확정 후 교체 예정 (현재 setTimeout mock).
🔧 변경 사항
📸 스크린샷 (선택 사항)
📄 기타
Summary by CodeRabbit
출시 노트