Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 25 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,29 @@ 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.
});
59 changes: 46 additions & 13 deletions frontend/src/components/WorkspaceHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,16 @@ function buildCompletionRate(tasks: TaskItem[]) {
return Math.round((tasks.filter((task) => task.status === 'done').length / tasks.length) * 100);
}

type DashboardDataStatus = 'loading' | 'ready' | 'unavailable';

function useDashboardData() {
const [emails, setEmails] = useState<EmailItem[]>([]);
const [pendingReplies, setPendingReplies] = useState<EmailItem[]>([]);
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [calendarSources, setCalendarSources] = useState<CalendarWritebackSource[]>([]);
const [projectFolders, setProjectFolders] = useState<ProjectFolder[]>([]);
const [loading, setLoading] = useState(true);
const [dashboardDataStatus, setDashboardDataStatus] = useState<DashboardDataStatus>('loading');
const [sourceEvidenceStatus, setSourceEvidenceStatus] = useState<'loading' | 'ready' | 'error'>('loading');

useEffect(() => {
Expand All @@ -166,14 +169,21 @@ function useDashboardData() {
};

Promise.all([
apiClient.get<{ emails: EmailItem[] }>('/api/emails').catch(() => ({ emails: [] })),
apiClient.get<{ emails: EmailItem[] }>('/api/emails/pending-replies?limit=3').catch(() => ({ emails: [] })),
apiClient.get<TaskItem[]>('/api/tasks').catch(() => []),
apiClient.get<{ emails: EmailItem[] }>('/api/emails'),
apiClient.get<{ emails: EmailItem[] }>('/api/emails/pending-replies?limit=3'),
apiClient.get<TaskItem[]>('/api/tasks'),
Comment thread
opencode-agent[bot] marked this conversation as resolved.
Outdated
]).then(([emailRes, pendingReplyRes, tasksRes]) => {
if (cancelled) return;
setEmails(Array.isArray(emailRes.emails) ? emailRes.emails : []);
setPendingReplies(Array.isArray(pendingReplyRes.emails) ? pendingReplyRes.emails : []);
setTasks(Array.isArray(tasksRes) ? tasksRes : []);
setDashboardDataStatus('ready');
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}).catch(() => {
if (cancelled) return;
setEmails([]);
setPendingReplies([]);
setTasks([]);
setDashboardDataStatus('unavailable');
Comment thread
opencode-agent[bot] marked this conversation as resolved.
Outdated
}).finally(finishRequest);

Promise.all([
Expand All @@ -196,7 +206,7 @@ function useDashboardData() {
};
}, []);

return { emails, pendingReplies, tasks, setTasks, calendarSources, projectFolders, loading, sourceEvidenceStatus };
return { emails, pendingReplies, tasks, setTasks, calendarSources, projectFolders, loading, dashboardDataStatus, sourceEvidenceStatus };
}

function formatStartupDate(value: string) {
Expand Down Expand Up @@ -239,7 +249,7 @@ function StartupResultList({ results }: { results: StartupSearchResult[] }) {
}

function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupView) => void }) {
const { emails, pendingReplies, tasks, setTasks: setDashboardTasks, calendarSources, projectFolders, loading, sourceEvidenceStatus } = useDashboardData();
const { emails, pendingReplies, tasks, setTasks: setDashboardTasks, calendarSources, projectFolders, loading, dashboardDataStatus, sourceEvidenceStatus } = useDashboardData();
const calendarCandidateEvidence = useStartupSearch('일정 충돌 일정 조율 회의 후보', 3);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [currentTimestamp, setCurrentTimestamp] = useState('');
Expand All @@ -250,18 +260,20 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
const completedTaskCount = tasks.filter((task) => task.status === 'done').length;
const writableCalendarSourceCount = calendarSources.filter(isWritableCalendarSource).length;
const taskCompletionRate = buildCompletionRate(tasks);
const dashboardDataUnavailable = dashboardDataStatus === 'unavailable';
const sourceEvidenceLoading = sourceEvidenceStatus === 'loading';
const sourceEvidenceError = sourceEvidenceStatus === 'error';
const dashboardStats = useMemo(() => ([
{ title: '받은 메일', value: loading ? '-' : emails.length.toString(), diff: unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: 'text-primary' },
{ title: '답변 대기', value: loading ? '-' : pendingReplyCount.toString(), diff: pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: 'text-rose-500' },
{ title: '받은 메일', value: dashboardDataUnavailable ? '오류' : loading ? '-' : emails.length.toString(), diff: dashboardDataUnavailable ? '확인 필요' : unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: 'text-primary' },
{ title: '답변 대기', value: dashboardDataUnavailable ? '오류' : loading ? '-' : pendingReplyCount.toString(), diff: dashboardDataUnavailable ? '확인 필요' : pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: 'text-rose-500' },
{ title: '일정 원본', value: sourceEvidenceError ? '오류' : sourceEvidenceLoading ? '-' : calendarSources.length.toString(), diff: sourceEvidenceError ? '확인 필요' : sourceEvidenceLoading ? '-' : `${writableCalendarSourceCount}개`, diffText: sourceEvidenceError ? '원본 확인' : '반영 가능', icon: CalendarDays, color: sourceEvidenceError ? 'text-red-500' : 'text-blue-500' },
{ title: '대기 작업', value: loading ? '-' : pendingTasks.length.toString(), diff: '-', diffText: 'source-linked', icon: CheckCircle2, color: 'text-green-500' },
{ title: '대기 작업', value: dashboardDataUnavailable ? '오류' : loading ? '-' : pendingTasks.length.toString(), diff: dashboardDataUnavailable ? '확인 필요' : '-', diffText: 'source-linked', icon: CheckCircle2, color: 'text-green-500' },
{ title: '프로젝트 원본', value: sourceEvidenceError ? '오류' : sourceEvidenceLoading ? '-' : projectFolders.length.toString(), diff: sourceEvidenceError ? '확인 필요' : sourceEvidenceLoading ? '-' : `${projectFolders.length}개`, diffText: 'WebDAV 폴더', icon: Network, color: sourceEvidenceError ? 'text-red-500' : 'text-purple-500' },
{ title: '작업 완료율', value: loading ? '-' : `${taskCompletionRate}%`, diff: loading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: 'text-emerald-500' },
{ title: '작업 완료율', value: dashboardDataUnavailable ? '오류' : loading ? '-' : `${taskCompletionRate}%`, diff: dashboardDataUnavailable ? '확인 필요' : loading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: 'text-emerald-500' },
]), [
calendarSources.length,
completedTaskCount,
dashboardDataUnavailable,
emails.length,
loading,
pendingReplyCount,
Expand Down Expand Up @@ -339,6 +351,21 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
</div>
</div>

{dashboardDataUnavailable ? (
<div role="alert" aria-label="대시보드 데이터 상태" className="rounded-2xl border border-destructive/30 bg-destructive/10 p-4 text-destructive">
<h2 className="font-bold">대시보드 데이터를 불러올 수 없습니다.</h2>
<p className="mt-1 text-sm">백엔드 연결을 확인한 후 다시 시도하세요.</p>
<button
type="button"
aria-label="대시보드 데이터 다시 시도"
onClick={() => window.location.reload()}
className="mt-3 min-h-11 rounded-lg border border-destructive/40 px-3 py-1.5 text-sm font-bold hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
다시 시도
</button>
</div>
) : null}

{/* KPI Cards */}
<div aria-label="홈 지표" className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{dashboardStats.map((stat) => (
Expand All @@ -362,8 +389,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
<div className="flex gap-4">
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-rose-100 text-rose-600"><Send className="size-5" /></div>
<div>
<p className="break-keep font-bold">답변 대기 {loading ? '-' : pendingReplyCount}건</p>
<p className="text-xs text-muted-foreground mt-1">보낸 메일 중 회신 확인이 필요한 항목입니다.</p>
<p className="break-keep font-bold">답변 대기 {loading ? '-' : dashboardDataUnavailable ? '확인 필요' : `${pendingReplyCount}건`}</p>
<p className="text-xs text-muted-foreground mt-1">{dashboardDataUnavailable ? '대시보드 데이터 연결을 확인할 수 없습니다.' : '보낸 메일 중 회신 확인이 필요한 항목입니다.'}</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<div className="mt-2 flex flex-wrap items-center gap-3">
<a href="/mail?folder=sent" className="inline-flex text-xs font-semibold text-primary hover:underline rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40">보낸 메일 보기</a>
<button
Expand Down Expand Up @@ -402,8 +429,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
<div className="flex gap-4 pt-4 md:pl-6 md:pt-0">
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-green-100 text-green-600"><CheckCircle2 className="size-5" /></div>
<div>
<p className="break-keep font-bold">완료 가능 작업 {loading ? '-' : pendingTasks.length}건</p>
<p className="text-xs text-muted-foreground mt-1">오늘 마감 전 완료해보세요.</p>
<p className="break-keep font-bold">완료 가능 작업 {loading ? '-' : dashboardDataUnavailable ? '확인 필요' : `${pendingTasks.length}건`}</p>
<p className="text-xs text-muted-foreground mt-1">{dashboardDataUnavailable ? '대시보드 데이터 연결을 확인할 수 없습니다.' : '오늘 마감 전 완료해보세요.'}</p>
<a href="/tasks" className="mt-2 inline-flex rounded-sm text-xs font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40">작업 바로가기</a>
</div>
</div>
Expand All @@ -421,6 +448,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
<div className="text-xs font-bold text-muted-foreground">답변 대기 메일</div>
{loading ? (
<div className="text-sm text-muted-foreground p-2">답변 대기 메일을 불러오는 중...</div>
) : dashboardDataUnavailable ? (
<div className="text-sm text-muted-foreground p-2">답변 대기 메일을 확인할 수 없습니다.</div>
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
) : pendingReplies.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">답변 대기 중인 보낸 메일이 없습니다.</div>
) : pendingReplies.map((reply) => {
Expand Down Expand Up @@ -450,6 +479,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
<div className="space-y-3">
{loading ? (
<div className="text-sm text-muted-foreground p-2">작업을 불러오는 중...</div>
) : dashboardDataUnavailable ? (
<div className="text-sm text-muted-foreground p-2">작업 데이터를 확인할 수 없습니다.</div>
) : pendingTasks.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">대기 작업이 없습니다.</div>
) : pendingTasks.slice(0, 3).map((task) => {
Expand Down Expand Up @@ -551,6 +582,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
<div className="space-y-3">
{loading ? (
<div className="text-sm text-muted-foreground p-2">메일을 불러오는 중...</div>
) : dashboardDataUnavailable ? (
<div className="text-sm text-muted-foreground p-2">메일 데이터를 확인할 수 없습니다.</div>
) : emails.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">수신된 메일이 없습니다.</div>
) : emails.slice(0, 5).map((mail) => (
Expand Down
13 changes: 13 additions & 0 deletions frontend/tests/e2e/dashboard-branding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,19 @@ test('renders Today dashboard pending reply lane with signed API headers', async
await page.screenshot({ path: testInfo.outputPath('today-pending-replies-mobile-scroll.png'), fullPage: false });
});

test('shows an actionable dashboard state when the backend is unavailable', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 1024 });
await page.route('**/api/**', (route) => route.abort());

await page.goto('/');

const alert = page.getByRole('alert', { name: '대시보드 데이터 상태' });
await expect(alert).toBeVisible();
await expect(alert).toContainText('대시보드 데이터를 불러올 수 없습니다.');
await expect(alert).toContainText('백엔드 연결을 확인한 후 다시 시도하세요.');
await expect(alert.getByRole('button', { name: '대시보드 데이터 다시 시도' })).toBeVisible();
});

test('keeps the short mobile AI quick action menu inside the viewport with scrollable actions', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 640 });
await mockDashboardApi(page);
Expand Down
Loading