Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
165 changes: 165 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,169 @@ 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([401, 403])("shows login recovery instead of retry for a %i core response", async (status) => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status })));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => container?.textContent?.includes("로그인이 필요합니다.") ?? false);

expect(container.textContent).toContain("세션이 만료됐거나 이 작업공간에 접근할 수 없습니다.");
expect(container.querySelector('a[href="/settings"]')?.textContent).toContain("로그인 설정 열기");
expect(container.querySelector('button[aria-label="대시보드 데이터 다시 시도"]')).toBeNull();
});

it("leaves loading state when a core read never returns", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
const timeoutController = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/emails") || url.endsWith("/api/emails/pending-replies?limit=3") || url.endsWith("/api/tasks")) {
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("timed out", "AbortError")), { once: true });
});
}
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 act(async () => timeoutController.abort());
await waitForCondition(() => container?.textContent?.includes("대시보드 데이터를 불러올 수 없습니다.") ?? false);

expect(container.textContent).not.toContain("메일을 불러오는 중...");
timeoutSpy.mockRestore();
});

it.each([
["mail missing emails", "/api/emails", {}, "수신된 메일이 없습니다."],
["mail", "/api/emails", { emails: "not-an-array" }, "수신된 메일이 없습니다."],
["pending-reply missing emails", "/api/emails/pending-replies?limit=3", {}, "답변 대기 중인 보낸 메일이 없습니다."],
["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