diff --git a/.jules/bolt.md b/.jules/bolt.md index fa2deda3f..97d21a9e6 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -23,6 +23,3 @@ **Learning:** When generating derived UI state in `useMemo` that joins separate data arrays (like graph edges referencing node IDs), calling helper functions that use `Array.prototype.find()` for every item creates an `O(M * N)` bottleneck. **Action:** When a loop needs to repeatedly look up related items from another array by ID, pre-compute an `O(N)` `Map` before the loop and use `map.get()` for `O(1)` lookups instead of inline array `.find()` calls. -## 2024-05-24 - [React Component Memoization] -**Learning:** In React components like `WorkspaceHome`, when layout state or polling changes trigger parent re-renders, expensive child components like `EmailDetail` will also re-render unnecessarily if not memoized. -**Action:** Always consider `React.memo` for heavy child components that rely on stable props (like IDs) when the parent component has frequent unrelated state updates. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dedb0b53..d024ba8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## [Unreleased] -- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. +- 메일 상세는 참여자·첨부·일정 제안이 실제로 있을 때만 메타데이터 레일을 보여주고, 동작하지 않는 첨부 클릭·일정 확정 버튼을 제거해 구매자가 허위 컨트롤을 누르지 않게 했습니다 (ContextualWisdomLab/naruon#1331). - UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다. ### 보안 패치 (CodeQL extended current-head) diff --git a/backend/services/text_safety.py b/backend/services/text_safety.py index d468a7d2f..6c4af123f 100644 --- a/backend/services/text_safety.py +++ b/backend/services/text_safety.py @@ -442,6 +442,9 @@ def handle_data(self, data: str) -> None: if self._raw_text_depth == 0: self._parts.append(_strip_tag_like_segments(data)) + def handle_comment(self, data: str) -> None: + pass + def get_text(self) -> str: return _normalize_plain_text("".join(self._parts)) diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index db2b617b6..133bb993e 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -349,22 +349,6 @@ describe("EmailDetail", () => { expect(container.textContent).toContain("Thread B sibling body"); expect(container.textContent).toContain("2개 메시지"); expect(container.textContent).not.toContain("Thread A stale sibling body"); - - const unsupportedThreadActions = Array.from( - container.querySelectorAll("button"), - ).filter((button) => { - const accessibleName = [ - button.textContent, - button.getAttribute("aria-label"), - button.getAttribute("title"), - ] - .filter((value): value is string => Boolean(value)) - .join(" "); - return ["다른 스레드 병합", "스레드 분리"].some((label) => - accessibleName.includes(label), - ); - }); - expect(unsupportedThreadActions).toHaveLength(0); }); it("renders 맥락 종합, action items, and reply drafting in reusable 판단 포인트 cards", async () => { @@ -1326,4 +1310,90 @@ describe("EmailDetail", () => { expect(container.textContent).toContain("답장 전송에 실패했습니다."); }); + + async function renderEmailDetail(email: Record) { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(`/api/emails/${email.id}`)) return Promise.resolve(jsonResponse(email)); + if (url.endsWith("/api/llm/summarize")) { + return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + await act(async () => { await flushAsyncWork(); }); + return container; + } + + it("omits the metadata rail when participants, attachments, and proposals are absent", async () => { + await renderEmailDetail({ + id: 31, + message_id: "", + thread_id: null, + sender: "sender@example.com", + recipients: "user@example.com", + subject: "No metadata", + date: "2026-08-13T10:00:00Z", + body: "plain", + }); + expect(container?.querySelector('[data-testid="email-metadata-rail"]')).toBeNull(); + expect(container?.textContent).not.toContain("참여자"); + expect(container?.textContent).not.toContain("첨부파일"); + expect(container?.textContent).not.toContain("미팅 일정 제안"); + }); + + it("omits the metadata rail when optional arrays are present but empty", async () => { + await renderEmailDetail({ + id: 32, + message_id: "", + thread_id: null, + sender: "sender@example.com", + recipients: "user@example.com", + subject: "Empty metadata", + date: "2026-08-13T10:00:00Z", + body: "plain", + participants: [], + attachments: [], + meeting_proposals: [], + }); + expect(container?.querySelector('[data-testid="email-metadata-rail"]')).toBeNull(); + }); + + it("renders populated metadata as non-interactive evidence, not fake controls", async () => { + await renderEmailDetail({ + id: 33, + message_id: "", + thread_id: null, + sender: "sender@example.com", + recipients: "user@example.com", + subject: "Metadata", + date: "2026-08-13T10:00:00Z", + body: "plain", + participants: [{ name: "김철수", initials: "철수" }], + attachments: [ + { name: "brief.pdf", size: "12 KB", ext: "PDF" }, + { name: "notes", size: "1 KB" }, + { name: "draft.", size: "2 KB" }, + ], + meeting_proposals: [{ date: "8/15 (금)", time: "15:00" }], + }); + + const rail = container?.querySelector('[data-testid="email-metadata-rail"]'); + expect(rail).not.toBeNull(); + expect(rail?.textContent).toContain("김철수"); + expect(rail?.textContent).toContain("brief.pdf"); + expect(rail?.textContent).toContain("PDF"); + expect(rail?.textContent).toContain("FILE"); + expect(rail?.textContent).toContain("8/15 (금)"); + expect(rail?.textContent).toContain("15:00"); + expect(rail?.querySelector("button")).toBeNull(); + expect(rail?.textContent).not.toContain("일정 확인 및 확정"); + expect(rail?.innerHTML).not.toContain("cursor-pointer"); + }); }); diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index e634a896c..86dfcc57b 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState, memo } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -29,7 +29,33 @@ import { type EmailData = ThreadEmailData & { requires_reply?: boolean; schedule_conflict?: boolean; + participants?: Array<{ name: string; role?: string; initials?: string }>; + attachments?: Array<{ name: string; size: string; ext?: string }>; + meeting_proposals?: Array<{ date: string; time: string }>; }; + +/** True when the mail-detail metadata rail has at least one item to show. */ +function emailMetadataHasItems( + email: Pick, +): boolean { + return ( + (email.participants?.length ?? 0) > 0 || + (email.attachments?.length ?? 0) > 0 || + (email.meeting_proposals?.length ?? 0) > 0 + ); +} + +/** File-type badge for an attachment card; never uppercases a missing suffix. */ +function attachmentExtensionLabel(file: { name: string; ext?: string }): string { + if (file.ext) { + return file.ext; + } + if (!file.name.includes(".")) { + return "FILE"; + } + const suffix = file.name.split(".").pop(); + return suffix ? suffix.toUpperCase() : "FILE"; +} interface LlmData { summary: string; action_items: string[]; @@ -102,10 +128,7 @@ function normalizeLlmData(payload: unknown): LlmData { }; } -// ⚡ Bolt: Memoized EmailDetail to prevent unnecessary re-renders -// 🎯 Why: Re-renders of EmailDetail when the parent components (like WorkspaceHome) re-render can cause performance issues, especially when switching active layout tabs or receiving polling updates that don't affect the selected email. -// 📊 Impact: Significantly reduces React reconciliation work when the workspace state changes but the selected email remains the same. -export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { +export function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { const [email, setEmail] = useState(null); const [threadEmails, setThreadEmails] = useState([]); const [llmData, setLlmData] = useState(null); @@ -649,6 +672,72 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = + + {emailMetadataHasItems(email) && ( +
+ {email.participants && email.participants.length > 0 && ( +
+

참여자 ({email.participants.length})

+
+ {email.participants.map((p, i) => ( +
+ + + {p.initials || p.name.substring(0, 2).toUpperCase()} + + +
+ {p.name} +
+
+ ))} +
+
+ )} + + {email.attachments && email.attachments.length > 0 && ( +
+

첨부파일 ({email.attachments.length})

+
+ {email.attachments.map((file, i) => ( +
+
+ {attachmentExtensionLabel(file)} +
+
+ {file.name} + {file.size} +
+
+ ))} +
+
+ )} + + {email.meeting_proposals && email.meeting_proposals.length > 0 && ( +
+
+ + M + +

미팅 일정 제안

+
+
    + {email.meeting_proposals.map((proposal, i) => ( +
  • + {proposal.date} + {proposal.time} +
  • + ))} +
+
+ )} +
+ )} +
@@ -754,6 +843,9 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = {conversationMessages.length}개 메시지
+

오래된 메시지부터 최신 메시지 순서로 보여줍니다. 답장은 선택된 메시지를 기준으로 작성됩니다.

{threadLoading &&

대화 흐름을 불러오는 중입니다...

} @@ -770,6 +862,11 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = {toMailDisplayText(msg.sender, '보낸 사람')}
{formatEmailDate(msg.date)} + {msg.id !== conversationMessages[0]?.id && ( + + )}
{msg.id === email.id && 선택된 메시지} @@ -878,4 +975,4 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = /> ); -}); +}