Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- Today 대시보드의 메일·답변 대기·작업 핵심 API가 실패할 때 응답을 빈 배열로 위장하지 않고 명시적인 unavailable 상태와 재시도 행동을 표시합니다. KPI와 각 목록도 확인 필요 상태를 유지해 백엔드 불가를 정상적인 0건으로 오인하지 않도록 했습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ now expose opaque `project_folders.folder_uid` values, scope listing by the
signed-session `user_id` and `organization_id`, and keep sequential folder
primary keys internal. `/dav` mutation methods fail closed until provider
execution can enforce source, capability, credential, and ETag/If-Match checks.
Today dashboard mail, pending-reply, and task reads fail closed as a group: a
failed core request is rendered as unavailable with a retry action, while a
successful empty response remains the only evidence for a zero-count state.
The Data workspace can create scoped workspace document rows through signed
`POST /api/data/documents` and can request reparse, embedding regeneration
intent, and HWP conversion intent for the selected opaque `document_id`; these
Expand Down
2 changes: 1 addition & 1 deletion docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ button, form, navigation, chart, or asynchronous data surface.

| Gap | Buyer problem | Protected/current evidence | Existing work | Completion evidence |
|---|---|---|---|---|
| Responsive shell hydration and unavailable state | a buyer can see a polished navigation shell but no actionable content when a data request is unavailable, and hydration drift can produce inconsistent controls | local fixed-origin capture showed tablet/mobile loading feedback, desktop blank content without the backend, and a development-server caret-style hydration mismatch; this is not a hosted release result | follow-up required; keep separate from #1470's bounded lookup optimization | deterministic server/client markup, explicit desktop unavailable/error state, backend-backed responsive Playwright evidence, and no hydration warnings |
| Responsive shell hydration and unavailable state | a buyer can see a polished navigation shell but no actionable content when a data request is unavailable, and hydration drift can produce inconsistent controls | local fixed-origin capture showed tablet/mobile loading feedback, desktop blank content without the backend, and a development-server caret-style hydration mismatch; this is not a hosted release result | local follow-up branch `feat/dashboard-unavailable-state` adds an explicit fail-closed dashboard state and retry action; protected `develop` remains unchanged and the work stays separate from #1470's bounded lookup optimization | deterministic server/client markup, explicit desktop unavailable/error state, backend-backed responsive Playwright evidence, and no hydration warnings |
| Stacked PR current-head review dispatch | a dependent PR can show only metadata while the central OpenCode/required checks are still being materialized on a non-default base branch | #1448 exact head `068aefdf…` received a targeted scheduler/ OpenCode dispatch and then merged normally; its merge-result checks on `62a0d645…` remain queued | #1443, #1448, ContextualWisdomLab/.github scheduler | every supported stack base receives a bounded exact-head OpenCode/Noema/required-check run, with queued/provider states observable and no false merge readiness |
| Typed Person/Event/Commitment graph | generic string graph cannot safely drive high-stakes action | planning spec marks types as new/planned | #977, #978, #1000 | normalized temporal/multi-membership identities, evidence/confidence/correction on every inferred edge |
| Status-weighted scheduling | calendar CRUD does not prevent harmful double booking | CalDAV source/writeback/retry foundation exists | #978, #988, #989, #990, #1416 | confirmed/tentative/desired + organizer/attendee + recurrence/free-busy/resource end-to-end |
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ describe("Home workspace action bridge", () => {
expect(fetch).toHaveBeenCalledWith("/api/search", expect.objectContaining({ method: "POST" }));
});

it("shows startup dashboard empty states and ignores malformed API payloads", async () => {
it("shows an unavailable dashboard state for malformed API payloads", async () => {
localStorage.setItem("naruon_startup_view", "dashboard");
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({
ok: true,
Expand All @@ -486,11 +486,12 @@ describe("Home workspace action bridge", () => {
await act(async () => {
root?.render(<Home />);
});
await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false);
await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]')));

expect(container.textContent).toContain("수신된 메일이 없습니다.");
expect(container.textContent).toContain("답변 대기 중인 보낸 메일이 없습니다.");
expect(container.textContent).toContain("대기 작업이 없습니다.");
expect(container.textContent).toContain("대시보드 데이터를 불러올 수 없습니다.");
expect(container.textContent).not.toContain("수신된 메일이 없습니다.");
expect(container.textContent).not.toContain("답변 대기 중인 보낸 메일이 없습니다.");
expect(container.textContent).not.toContain("대기 작업이 없습니다.");
});

it("shows desktop calendar empty and error states from the search API", async () => {
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/components/WorkspaceHome.dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -787,4 +787,109 @@ describe("WorkspaceHome Today dashboard", () => {
expect(container.textContent).toContain("일정 원본 목록 응답을 확인할 수 없습니다.");
expect(container.textContent).not.toContain("연결된 일정 원본이 없습니다.");
});

it("shows an actionable unavailable state instead of false empty dashboard data", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("backend unavailable"))));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]')));

const alert = container.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]');
expect(alert?.textContent).toContain("대시보드 데이터를 불러올 수 없습니다.");
expect(alert?.textContent).toContain("데이터 연결에 일시적인 문제가 있습니다. 잠시 후 다시 시도하세요.");
expect(container.querySelector('button[aria-label="대시보드 데이터 다시 시도"]')).not.toBeNull();
expect(container.textContent).not.toContain("수신된 메일이 없습니다.");
expect(container.textContent).not.toContain("대기 작업이 없습니다.");
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

it.each([
["mail", "/api/emails", { emails: "not-an-array" }, "수신된 메일이 없습니다."],
["pending-reply", "/api/emails/pending-replies?limit=3", { emails: null }, "답변 대기 중인 보낸 메일이 없습니다."],
["task", "/api/tasks", { tasks: "not-an-array" }, "대기 작업이 없습니다."],
])("fails closed for a malformed %s response", async (_name, malformedUrl, malformedResponse, falseEmptyState) => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(malformedUrl)) {
return Promise.resolve({ ok: true, json: async () => malformedResponse });
}
if (url.endsWith("/api/emails")) {
return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) });
}
if (url.endsWith("/api/emails/pending-replies?limit=3")) {
return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) });
}
if (url.endsWith("/api/tasks")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
const sourceEvidenceResponse = emptySourceEvidenceResponse(url);
if (sourceEvidenceResponse) return sourceEvidenceResponse;
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]')));

expect(container.textContent).toContain("대시보드 데이터를 불러올 수 없습니다.");
expect(container.textContent).not.toContain(falseEmptyState);
});

it("renders core data as unavailable while source evidence remains pending", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/emails")) return Promise.reject(new Error("mail backend unavailable"));
if (url.endsWith("/api/emails/pending-replies?limit=3")) {
return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) });
}
if (url.endsWith("/api/tasks")) return Promise.resolve({ ok: true, json: async () => [] });
if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) {
return new Promise(() => undefined);
}
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]')));

expect(container.textContent).toContain("메일 데이터를 확인할 수 없습니다.");
expect(container.textContent).not.toContain("메일을 불러오는 중...");
expect(container.textContent).not.toContain("답변 대기 메일을 불러오는 중...");
expect(container.textContent).not.toContain("작업을 불러오는 중...");
});
});
Loading
Loading