From 47c53b15d83b299777002f320c46578fce93ac96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:47:19 +0900 Subject: [PATCH 1/6] test(search): require empty-result live announcement Add a focused asynchronous loading-to-empty regression on the current Search owner. The unchanged owner source lacks a status live region for the empty result, so this commit is the source-order RED. Signed-off-by: Seongho Bae --- .../SearchLayout.empty-live-region.test.tsx | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/src/components/SearchLayout.empty-live-region.test.tsx diff --git a/frontend/src/components/SearchLayout.empty-live-region.test.tsx b/frontend/src/components/SearchLayout.empty-live-region.test.tsx new file mode 100644 index 000000000..aae82c9c1 --- /dev/null +++ b/frontend/src/components/SearchLayout.empty-live-region.test.tsx @@ -0,0 +1,107 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} + ), +})); + +vi.mock("lucide-react", () => ({ + AlertCircle: () => , + CalendarDays: () => , + CheckCircle2: () => , + Clock: () => , + CornerDownRight: () => , + FileText: () => , + Loader2: () => , + Mail: () => , + Network: () => , + Search: () => , + Sparkles: () => , + X: () => , +})); + +import { SearchLayout } from "./SearchLayout"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 20; index += 1) { + if (condition()) return; + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + throw new Error("waitForCondition timed out after 20 attempts"); +} + +function statusWithText(container: HTMLElement, text: string) { + return Array.from(container.querySelectorAll('[role="status"]')).find( + (element) => element.textContent?.includes(text), + ) ?? null; +} + +describe("SearchLayout empty-result live region", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("announces an empty result after the asynchronous search leaves loading", async () => { + let resolveSearch!: (response: Response) => void; + const searchResponse = new Promise((resolve) => { + resolveSearch = resolve; + }); + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search/answer")) { + return Promise.resolve(jsonResponse({ answer: null, citations: [], provenance: null })); + } + if (url.endsWith("/api/search")) return searchResponse; + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(statusWithText(container, "맥락 검색 결과를 불러오는 중입니다.")).not.toBeNull(); + expect(statusWithText(container, "맥락 검색 결과가 없습니다.")).toBeNull(); + + await act(async () => { + resolveSearch(jsonResponse({ results: [] })); + }); + await waitForCondition(() => statusWithText(container as HTMLElement, "맥락 검색 결과가 없습니다.") !== null); + + const emptyStatus = statusWithText(container, "맥락 검색 결과가 없습니다."); + expect(emptyStatus).not.toBeNull(); + expect(emptyStatus?.getAttribute("aria-live")).toBe("polite"); + expect(container.textContent).not.toContain("맥락 검색 결과를 불러오는 중입니다."); + }); +}); \ No newline at end of file From d99d88ab2d67c80fc1042f6ebbb2e8627214d5b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:47:30 +0900 Subject: [PATCH 2/6] fix(search): announce asynchronous empty results Keep the existing loading status and make the post-search empty result a polite status live region. This is the minimal source repair for the focused loading-to-empty regression and changes no visual styling or Search domain behavior. Signed-off-by: Seongho Bae --- frontend/src/components/SearchLayout.tsx | 77 ++++++------------------ 1 file changed, 20 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 5cf5ad72f..e94897e57 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -56,45 +56,7 @@ const EVIDENCE_KIND_LABELS: Record = { function evidenceKindLabel(kind: string | null | undefined) { if (!kind || kind === "email_body") return null; - return EVIDENCE_KIND_LABELS[kind] ?? "연결 근거"; -} - -const RELATIONSHIP_TYPE_LABELS: Record = { - sender_context: "발신자 맥락", - colleague: "업무 관계", -}; - -function relationshipTypeLabel(relationshipType: string) { - return RELATIONSHIP_TYPE_LABELS[relationshipType.trim().toLowerCase()] ?? "연결 관계"; -} - -const CUSTOMER_FACING_RELATIONSHIP_ACTIONS: Record = { - summarize_then_archive: "요약 후 보관합니다.", - track_reply_and_tasks: "답장과 후속 작업을 확인합니다.", - prepare_response_draft: "답장 초안을 준비합니다.", - classify_sender: "발신자 관계를 확인합니다.", - "계약 검토 담당자를 확인합니다.": "계약 검토 담당자를 확인합니다.", - "후속 작업을 확인합니다.": "후속 작업을 확인합니다.", -}; - -const CUSTOMER_FACING_RELATIONSHIP_REASONS: Record = { - summarize_then_archive: "핵심 내용을 확인한 뒤 정리할 수 있습니다.", - track_reply_and_tasks: "답장 여부와 이어서 할 일을 놓치지 않도록 제안했습니다.", - prepare_response_draft: "대화를 이어갈 답장이 필요한 관계입니다.", - classify_sender: "알맞은 후속 행동을 정하려면 발신자 관계 확인이 필요합니다.", -}; - -export function customerFacingRelationshipText( - relationshipAction: string, - fallbackCopy: string, -) { - const normalizedAction = relationshipAction.trim(); - return CUSTOMER_FACING_RELATIONSHIP_ACTIONS[normalizedAction] ?? fallbackCopy; -} - -export function customerFacingRelationshipReason(relationshipAction: string) { - return CUSTOMER_FACING_RELATIONSHIP_REASONS[relationshipAction.trim()] ?? - "선택한 원본과 발신자 관계를 바탕으로 제안했습니다."; + return EVIDENCE_KIND_LABELS[kind] ?? kind; } type SearchResponse = { @@ -227,7 +189,7 @@ function SenderDagPanel({ aria-live="polite" className="rounded-lg border border-border bg-background p-4 text-sm font-semibold text-muted-foreground" > - 발신자 관계를 불러오는 중입니다. + 발신자 DAG를 불러오는 중입니다. ); } @@ -250,7 +212,7 @@ function SenderDagPanel({ {canCapture ? (

- 선택한 원본 메일을 기준으로 관계와 다음 행동을 확인합니다. + 원본 메일의 sender/thread 근거로 관계와 다음 액션을 캡처합니다.

- {relationshipTypeLabel(relationship.relationship_type)} ·{" "} + {relationship.relationship_type} ·{" "} {(relationship.confidence_score * 100).toFixed(0)}%

- {customerFacingRelationshipText( - relationship.next_action, - "후속 작업을 확인합니다.", - )} + {relationship.next_action}

-

다음 행동

+

Agent next action

- {customerFacingRelationshipReason(relationship.next_action)} + {relationship.action_reason}

판단 근거

+

+ source={relationship.source_message_id ?? "global"} / thread= + {relationship.source_thread_id ?? "none"} +

))} @@ -568,7 +531,7 @@ export function SearchLayout() { setRelationshipState({ sourceKey: activeOntologySourceKey, items: [], - error: "발신자 관계를 불러오지 못했습니다.", + error: "발신자 DAG를 불러오지 못했습니다.", }); }); @@ -654,7 +617,7 @@ export function SearchLayout() { {error} ) : filteredResults.length === 0 ? ( -
+
맥락 검색 결과가 없습니다.
) : ( @@ -930,10 +893,10 @@ export function SearchLayout() {

증거 바인딩

- {activeResult.source_message_id ? "선택한 원본 메일을 기준으로 관계를 확인합니다." : "연결할 원본 메일이 없는 결과입니다."} + {activeResult.source_message_id ? "원본 메시지 필터로 관계 API를 조회합니다." : "원본 메시지 필터가 없는 결과입니다."}

- 다른 메일의 관계가 섞이지 않도록 선택한 결과 안에서만 확인합니다. + 관계 조회는 선택된 맥락 검색 결과의 source/thread 범위 안에서만 수행됩니다.

@@ -976,7 +939,7 @@ export function SearchLayout() {

관계 맥락과 타임라인

- 원본 근거 연결 + source/thread API 연결
@@ -988,7 +951,7 @@ export function SearchLayout() { aria-hidden="true" />

- 발신자 관계 + 발신자 DAG (Ontology)

@@ -1036,7 +999,7 @@ export function SearchLayout() {

{activeResult.thread_id ? ( @@ -1050,7 +1013,7 @@ export function SearchLayout() { className="size-4" aria-hidden="true" /> - 메일 흐름 연결됨 + {activeResult.thread_id}
) : null} From da2c9f8e27d60b5c6b826f4a73bad3baa62d99d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:49:08 +0900 Subject: [PATCH 3/6] repair(search): preserve customer-copy owner before live-region fix The generated #1633 SearchLayout blob was based on protected develop and reverted valid #1603 customer-copy semantics when overlaid directly. Restore the exact #1603 source while retaining only the focused live-region RED test. Keep this branch RED-only until the one-line semantic fix is applied without replacing owner content. Signed-off-by: Seongho Bae --- frontend/src/components/SearchLayout.tsx | 77 ++++++++++++++++++------ 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index e94897e57..5cf5ad72f 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -56,7 +56,45 @@ const EVIDENCE_KIND_LABELS: Record = { function evidenceKindLabel(kind: string | null | undefined) { if (!kind || kind === "email_body") return null; - return EVIDENCE_KIND_LABELS[kind] ?? kind; + return EVIDENCE_KIND_LABELS[kind] ?? "연결 근거"; +} + +const RELATIONSHIP_TYPE_LABELS: Record = { + sender_context: "발신자 맥락", + colleague: "업무 관계", +}; + +function relationshipTypeLabel(relationshipType: string) { + return RELATIONSHIP_TYPE_LABELS[relationshipType.trim().toLowerCase()] ?? "연결 관계"; +} + +const CUSTOMER_FACING_RELATIONSHIP_ACTIONS: Record = { + summarize_then_archive: "요약 후 보관합니다.", + track_reply_and_tasks: "답장과 후속 작업을 확인합니다.", + prepare_response_draft: "답장 초안을 준비합니다.", + classify_sender: "발신자 관계를 확인합니다.", + "계약 검토 담당자를 확인합니다.": "계약 검토 담당자를 확인합니다.", + "후속 작업을 확인합니다.": "후속 작업을 확인합니다.", +}; + +const CUSTOMER_FACING_RELATIONSHIP_REASONS: Record = { + summarize_then_archive: "핵심 내용을 확인한 뒤 정리할 수 있습니다.", + track_reply_and_tasks: "답장 여부와 이어서 할 일을 놓치지 않도록 제안했습니다.", + prepare_response_draft: "대화를 이어갈 답장이 필요한 관계입니다.", + classify_sender: "알맞은 후속 행동을 정하려면 발신자 관계 확인이 필요합니다.", +}; + +export function customerFacingRelationshipText( + relationshipAction: string, + fallbackCopy: string, +) { + const normalizedAction = relationshipAction.trim(); + return CUSTOMER_FACING_RELATIONSHIP_ACTIONS[normalizedAction] ?? fallbackCopy; +} + +export function customerFacingRelationshipReason(relationshipAction: string) { + return CUSTOMER_FACING_RELATIONSHIP_REASONS[relationshipAction.trim()] ?? + "선택한 원본과 발신자 관계를 바탕으로 제안했습니다."; } type SearchResponse = { @@ -189,7 +227,7 @@ function SenderDagPanel({ aria-live="polite" className="rounded-lg border border-border bg-background p-4 text-sm font-semibold text-muted-foreground" > - 발신자 DAG를 불러오는 중입니다. + 발신자 관계를 불러오는 중입니다. ); } @@ -212,7 +250,7 @@ function SenderDagPanel({ {canCapture ? (

- 원본 메일의 sender/thread 근거로 관계와 다음 액션을 캡처합니다. + 선택한 원본 메일을 기준으로 관계와 다음 행동을 확인합니다.

- {relationship.relationship_type} ·{" "} + {relationshipTypeLabel(relationship.relationship_type)} ·{" "} {(relationship.confidence_score * 100).toFixed(0)}%

- {relationship.next_action} + {customerFacingRelationshipText( + relationship.next_action, + "후속 작업을 확인합니다.", + )}

-

Agent next action

+

다음 행동

- {relationship.action_reason} + {customerFacingRelationshipReason(relationship.next_action)}

판단 근거

-

- source={relationship.source_message_id ?? "global"} / thread= - {relationship.source_thread_id ?? "none"} -

))} @@ -531,7 +568,7 @@ export function SearchLayout() { setRelationshipState({ sourceKey: activeOntologySourceKey, items: [], - error: "발신자 DAG를 불러오지 못했습니다.", + error: "발신자 관계를 불러오지 못했습니다.", }); }); @@ -617,7 +654,7 @@ export function SearchLayout() { {error} ) : filteredResults.length === 0 ? ( -
+
맥락 검색 결과가 없습니다.
) : ( @@ -893,10 +930,10 @@ export function SearchLayout() {

증거 바인딩

- {activeResult.source_message_id ? "원본 메시지 필터로 관계 API를 조회합니다." : "원본 메시지 필터가 없는 결과입니다."} + {activeResult.source_message_id ? "선택한 원본 메일을 기준으로 관계를 확인합니다." : "연결할 원본 메일이 없는 결과입니다."}

- 관계 조회는 선택된 맥락 검색 결과의 source/thread 범위 안에서만 수행됩니다. + 다른 메일의 관계가 섞이지 않도록 선택한 결과 안에서만 확인합니다.

@@ -939,7 +976,7 @@ export function SearchLayout() {

관계 맥락과 타임라인

- source/thread API 연결 + 원본 근거 연결
@@ -951,7 +988,7 @@ export function SearchLayout() { aria-hidden="true" />

- 발신자 DAG (Ontology) + 발신자 관계

@@ -999,7 +1036,7 @@ export function SearchLayout() {

{activeResult.thread_id ? ( @@ -1013,7 +1050,7 @@ export function SearchLayout() { className="size-4" aria-hidden="true" /> - {activeResult.thread_id} + 메일 흐름 연결됨
) : null} From da9ce3a058e010e5bfdcacea8ccc874df11fc4c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:50:37 +0900 Subject: [PATCH 4/6] fix(search): isolate generated empty-status semantic patch Isolate only the generated SearchLayout live-region change on protected-develop ancestry so Git can perform an ordinary three-way integration into the active Search owner without copying stale generated customer-copy source. Signed-off-by: Seongho Bae --- frontend/src/components/SearchLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 92f16b184..e94897e57 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -617,7 +617,7 @@ export function SearchLayout() { {error} ) : filteredResults.length === 0 ? ( -
+
맥락 검색 결과가 없습니다.
) : ( From e64a1181c234c9213774bcd5c2d4c4288bb889b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:02:09 +0900 Subject: [PATCH 5/6] test(search): remove timer polling from live-region regression Make the async loading-to-empty contract deterministic by resolving and flushing the controlled response chain inside React act instead of polling real zero-delay timers. Preserve the exact one-line Search source delta. Signed-off-by: Seongho Bae --- .../SearchLayout.empty-live-region.test.tsx | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/SearchLayout.empty-live-region.test.tsx b/frontend/src/components/SearchLayout.empty-live-region.test.tsx index aae82c9c1..5e6b527bf 100644 --- a/frontend/src/components/SearchLayout.empty-live-region.test.tsx +++ b/frontend/src/components/SearchLayout.empty-live-region.test.tsx @@ -39,17 +39,6 @@ function jsonResponse(body: unknown) { }); } -async function waitForCondition(condition: () => boolean) { - for (let index = 0; index < 20; index += 1) { - if (condition()) return; - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - throw new Error("waitForCondition timed out after 20 attempts"); -} - function statusWithText(container: HTMLElement, text: string) { return Array.from(container.querySelectorAll('[role="status"]')).find( (element) => element.textContent?.includes(text), @@ -73,6 +62,7 @@ describe("SearchLayout empty-result live region", () => { const searchResponse = new Promise((resolve) => { resolveSearch = resolve; }); + const emptyBody = Promise.resolve({ results: [] }); vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -95,9 +85,13 @@ describe("SearchLayout empty-result live region", () => { expect(statusWithText(container, "맥락 검색 결과가 없습니다.")).toBeNull(); await act(async () => { - resolveSearch(jsonResponse({ results: [] })); + resolveSearch(new Response(JSON.stringify(await emptyBody), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + await searchResponse; + await emptyBody; }); - await waitForCondition(() => statusWithText(container as HTMLElement, "맥락 검색 결과가 없습니다.") !== null); const emptyStatus = statusWithText(container, "맥락 검색 결과가 없습니다."); expect(emptyStatus).not.toBeNull(); From 8ca8767c09810945a1348bdb1c4b862df1a87f96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:12:14 +0900 Subject: [PATCH 6/6] test(search): await controlled response body transition --- .../SearchLayout.empty-live-region.test.tsx | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/SearchLayout.empty-live-region.test.tsx b/frontend/src/components/SearchLayout.empty-live-region.test.tsx index 5e6b527bf..0578e46be 100644 --- a/frontend/src/components/SearchLayout.empty-live-region.test.tsx +++ b/frontend/src/components/SearchLayout.empty-live-region.test.tsx @@ -39,6 +39,36 @@ function jsonResponse(body: unknown) { }); } +type DeferredJsonResponse = { + responsePromise: Promise; + bodyPromise: Promise; + resolveResponse: () => void; + resolveBody: (body: unknown) => void; +}; + +function deferredJsonResponse(): DeferredJsonResponse { + let resolveResponsePromise!: (response: Response) => void; + let resolveBodyPromise!: (body: unknown) => void; + const bodyPromise = new Promise((resolve) => { + resolveBodyPromise = resolve; + }); + const responsePromise = new Promise((resolve) => { + resolveResponsePromise = resolve; + }); + const response = new Response(null, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + vi.spyOn(response, "json").mockImplementation(() => bodyPromise); + + return { + responsePromise, + bodyPromise, + resolveResponse: () => resolveResponsePromise(response), + resolveBody: resolveBodyPromise, + }; +} + function statusWithText(container: HTMLElement, text: string) { return Array.from(container.querySelectorAll('[role="status"]')).find( (element) => element.textContent?.includes(text), @@ -57,19 +87,15 @@ describe("SearchLayout empty-result live region", () => { vi.unstubAllGlobals(); }); - it("announces an empty result after the asynchronous search leaves loading", async () => { - let resolveSearch!: (response: Response) => void; - const searchResponse = new Promise((resolve) => { - resolveSearch = resolve; - }); - const emptyBody = Promise.resolve({ results: [] }); + it("announces an empty result only after the asynchronous response body leaves loading", async () => { + const searchResponse = deferredJsonResponse(); vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/search/answer")) { return Promise.resolve(jsonResponse({ answer: null, citations: [], provenance: null })); } - if (url.endsWith("/api/search")) return searchResponse; + if (url.endsWith("/api/search")) return searchResponse.responsePromise; throw new Error(`Unexpected fetch: ${url}`); })); @@ -85,12 +111,16 @@ describe("SearchLayout empty-result live region", () => { expect(statusWithText(container, "맥락 검색 결과가 없습니다.")).toBeNull(); await act(async () => { - resolveSearch(new Response(JSON.stringify(await emptyBody), { - status: 200, - headers: { "Content-Type": "application/json" }, - })); - await searchResponse; - await emptyBody; + searchResponse.resolveResponse(); + await searchResponse.responsePromise; + }); + + expect(statusWithText(container, "맥락 검색 결과를 불러오는 중입니다.")).not.toBeNull(); + expect(statusWithText(container, "맥락 검색 결과가 없습니다.")).toBeNull(); + + await act(async () => { + searchResponse.resolveBody({ results: [] }); + await searchResponse.bodyPromise; }); const emptyStatus = statusWithText(container, "맥락 검색 결과가 없습니다."); @@ -98,4 +128,4 @@ describe("SearchLayout empty-result live region", () => { expect(emptyStatus?.getAttribute("aria-live")).toBe("polite"); expect(container.textContent).not.toContain("맥락 검색 결과를 불러오는 중입니다."); }); -}); \ No newline at end of file +});