@@ -184,26 +239,34 @@ return (
type="file"
accept=".txt,.md,.markdown,text/plain,text/markdown"
className="sr-only"
+ disabled={documentActionPendingAction !== null}
onChange={handleDocumentFileChange}
/>
void requestDocumentUpload()}
- disabled={isDocumentActionLoading || documentUploadFiles.length === 0}
- aria-busy={isDocumentActionLoading}
+ disabled={documentActionPendingAction !== null || documentUploadFiles.length === 0}
+ aria-busy={documentActionPendingAction === 'upload'}
className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-fit"
>
-
- 선택 문서 저장
+ {documentActionPendingAction === 'upload' ? (
+
+ ) : (
+
+ )}
+ {documentActionPendingAction === 'upload' ? '저장 중' : '선택 문서 저장'}
{documentActionStatus === 'idle' && documentUploadFiles.length === 0 && '텍스트, Markdown, HWP 원본을 워크스페이스 문서 근거로 저장합니다.'}
{documentActionStatus === 'idle' && documentUploadFiles.length > 0 && `${documentUploadFiles[0]?.name ?? '문서'} 선택됨`}
{documentActionStatus === 'loading' && '문서 작업을 처리하는 중입니다.'}
- {documentActionStatus === 'auth' &&
signed session이 필요합니다. 공개 identity header로는 문서 작업을 실행할 수 없습니다. }
- {documentActionStatus === 'error' &&
문서 작업에 실패했습니다. }
+ {documentActionStatus === 'auth' &&
로그인이 만료되었습니다. 다시 로그인한 후 재시도하세요. }
+ {documentActionStatus === 'conflict' &&
문서 처리가 아직 끝나지 않았습니다. 처리가 완료된 후 재시도하세요. }
+ {documentActionStatus === 'invalid' &&
쓸 수 있는 문서 내용 또는 WebDAV 저장소를 확인한 후 재시도하세요. }
+ {documentActionStatus === 'connector_error' &&
WebDAV 쓰기를 완료하지 못했습니다. 연결 상태를 확인한 후 재시도하거나 관리자에게 문의하세요. }
+ {documentActionStatus === 'error' &&
문서 작업을 완료하지 못했습니다. 잠시 후 재시도하세요. }
{documentActionStatus === 'success' && documentActionResult && (
{toSafeReactText(documentActionResult.document_name)} · {toSafeReactText(documentActionResult.message)} · {getWriteBoundaryLabel(documentActionResult.provider_write_executed)}
@@ -233,9 +296,13 @@ return (
setSelectedWebdavSourceId(account.source_id)}
+ onClick={() => {
+ if (documentActionPendingAction === null) {
+ setSelectedWebdavSourceId(account.source_id);
+ }
+ }}
className={`flex min-w-0 items-start gap-2 rounded-lg border p-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-70 ${
accountSelected ? 'border-primary bg-primary/10' : 'border-transparent bg-secondary/50 hover:border-primary/40'
}`}
@@ -293,6 +360,7 @@ return (
)}
{repositoryAssets.map((asset) => {
const assetSelected = selectedRepositoryAsset?.asset_key === asset.asset_key;
+ const assetSelectionLocked = documentActionPendingAction !== null;
@@ -300,17 +368,27 @@ return (
setSelectedRepositoryAssetKey(asset.asset_key)}
+ aria-disabled={assetSelectionLocked}
+ onClick={() => {
+ if (!assetSelectionLocked) {
+ setSelectedRepositoryAssetKey(asset.asset_key);
+ }
+ }}
onKeyDown={(event) => {
+ if (assetSelectionLocked) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedRepositoryAssetKey(asset.asset_key);
}
}}
- className={`cursor-pointer rounded-xl border bg-background p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${
- assetSelected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'
+ className={`rounded-xl border bg-background p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${
+ assetSelectionLocked
+ ? 'cursor-not-allowed opacity-60'
+ : assetSelected
+ ? 'cursor-pointer border-primary bg-primary/5'
+ : 'cursor-pointer border-border hover:border-primary/40'
}`}
>
@@ -370,45 +448,97 @@ return (
{selectedWorkspaceDocument && (
+ <>
void requestDocumentAction('reparse')}
- disabled={isDocumentActionLoading}
+ disabled={documentActionPendingAction !== null}
+ aria-busy={documentActionPendingAction === 'reparse'}
className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary disabled:cursor-wait disabled:opacity-60"
>
-
- 재파싱 실행
+ {documentActionPendingAction === 'reparse' ? (
+
+ ) : (
+
+ )}
+ {documentActionPendingAction === 'reparse' ? '상태 변경 중' : '파싱 완료로 표시'}
void requestDocumentAction('embedding-regeneration-intent')}
- disabled={isDocumentActionLoading}
+ disabled={documentActionPendingAction !== null}
+ aria-busy={documentActionPendingAction === 'embedding-regeneration-intent'}
className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary disabled:cursor-wait disabled:opacity-60"
>
-
- 임베딩 재생성 의도
+ {documentActionPendingAction === 'embedding-regeneration-intent' ? (
+
+ ) : (
+
+ )}
+ {documentActionPendingAction === 'embedding-regeneration-intent' ? '요청 등록 중' : '임베딩 재생성 요청 등록'}
void requestDocumentAction('hwp-conversion-intent')}
- disabled={isDocumentActionLoading}
+ disabled={documentActionPendingAction !== null}
+ aria-busy={documentActionPendingAction === 'hwp-conversion-intent'}
className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary disabled:cursor-wait disabled:opacity-60"
>
-
- HWP 변환 의도
+ {documentActionPendingAction === 'hwp-conversion-intent' ? (
+
+ ) : (
+
+ )}
+ {documentActionPendingAction === 'hwp-conversion-intent' ? '요청 등록 중' : 'HWP 변환 요청 등록'}
void requestDocumentAction('webdav-materialization-intent')}
- disabled={isDocumentActionLoading || !selectedWebdavAccount || selectedWorkspaceDocument.state_code !== 'ready'}
- aria-busy={isDocumentActionLoading}
+ onClick={() => setWebdavWriteConfirmationKey(currentWebdavWriteConfirmationKey)}
+ disabled={documentActionPendingAction !== null || !selectedWebdavAccount || selectedWorkspaceDocument.state_code !== 'ready'}
+ aria-busy={documentActionPendingAction === 'webdav-materialization-intent'}
className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
-
- WebDAV 문서 실행 요청
+ {documentActionPendingAction === 'webdav-materialization-intent' ? (
+
+ ) : (
+
+ )}
+ {documentActionPendingAction === 'webdav-materialization-intent' ? 'WebDAV에 쓰는 중' : '고객 WebDAV에 문서 쓰기'}
+ {webdavWriteConfirmationKey === currentWebdavWriteConfirmationKey && documentActionPendingAction === null ? (
+
+
+
+
고객 WebDAV에 문서를 쓰시겠습니까?
+
+ {selectedWebdavAccountLabel}에 현재 문서를 기록합니다. 기존 파일이 있으면 충돌 조건을 확인하며, 이 작업은 고객 원본 저장소를 변경합니다.
+
+
+ 취소
+ {
+ closeWebdavWriteConfirmation();
+ void requestDocumentAction('webdav-materialization-intent');
+ }}
+ className="min-h-10 rounded-lg bg-primary px-3 py-2 text-sm font-bold text-primary-foreground"
+ >WebDAV 쓰기 확인
+
+
+
+ ) : null}
+ >
)}
@@ -452,9 +582,10 @@ return (
onClick={() => void requestWebdavWritebackIntent()}
disabled={isWritebackLoading || !canRequestWebdavWriteback}
aria-busy={isWebdavSourceLoading || isWritebackLoading}
- className="w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
+ className="inline-flex items-center justify-center gap-2 w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
>
- WebDAV 반영 의도 점검
+ {isWritebackLoading && }
+ {isWritebackLoading ? '점검 중' : 'WebDAV 반영 의도 점검'}
@@ -518,9 +649,11 @@ return (
type="button"
onClick={() => void requestUniqueThreadIntent()}
disabled={isUniqueThreadLoading}
- className="w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60 sm:w-auto"
+ aria-busy={isUniqueThreadLoading}
+ className="inline-flex items-center justify-center gap-2 w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60 sm:w-auto"
>
- 중복 메일 스레드 의도 점검
+ {isUniqueThreadLoading && }
+ {isUniqueThreadLoading ? '점검 중' : '중복 메일 스레드 의도 점검'}
diff --git a/frontend/src/components/data-layout/DocumentRepositoryTab.webdav-dialog.test.tsx b/frontend/src/components/data-layout/DocumentRepositoryTab.webdav-dialog.test.tsx
new file mode 100644
index 000000000..78c78dd20
--- /dev/null
+++ b/frontend/src/components/data-layout/DocumentRepositoryTab.webdav-dialog.test.tsx
@@ -0,0 +1,191 @@
+/* @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('lucide-react', () => ({
+ HardDrive: () =>
,
+ Upload: () =>
,
+ Loader2: () =>
,
+ FileText: () =>
,
+ FolderOpen: () =>
,
+ Database: () =>
,
+ RefreshCw: () =>
,
+ CheckCircle2: () =>
,
+ Server: () =>
,
+}));
+
+import { DocumentRepositoryTab } from './DocumentRepositoryTab';
+
+const webdavAccount = {
+ source_id: 'webdav-customer',
+ display_label: 'Customer WebDAV',
+ writeback_enabled: true,
+ etag: 'etag-1',
+};
+
+const repositoryAsset = {
+ asset_key: 'asset-roadmap',
+ asset_type: 'workspace_document' as const,
+ display_name: 'roadmap.md',
+ source_label: 'customer-webdav',
+ state_code: 'ready' as const,
+ detail_text: 'Signed workspace document evidence',
+ content_chars: 128,
+ captured_at: '2026-09-06T00:00:00Z',
+ evidence_source: 'signed-workspace',
+ thread_key: 'thread-roadmap',
+ provider_write_executed: false,
+};
+
+function buildProps(requestDocumentAction = vi.fn()) {
+ return {
+ writebackStatus: 'idle',
+ writebackResult: null,
+ requestWebdavWritebackIntent: vi.fn(),
+ isWritebackLoading: false,
+ canRequestWebdavWriteback: true,
+ selectedWebdavAccount: webdavAccount,
+ isWebdavSourceLoading: false,
+ setSelectedWebdavSourceId: vi.fn(),
+ uniqueThreadStatus: 'idle',
+ uniqueThreadResult: null,
+ requestUniqueThreadIntent: vi.fn(),
+ isUniqueThreadLoading: false,
+ connectorEvents: [],
+ dataSurfaceStatus: 'ready' as const,
+ dataQualitySurface: null,
+ embeddingStage: null,
+ emailRepository: { object_count: 0 },
+ attachmentRepository: { object_count: 0 },
+ handleEmailImportFileChange: vi.fn(),
+ requestEmailFileImport: vi.fn(),
+ isEmailImportLoading: false,
+ emailImportFiles: [],
+ emailImportStatus: 'idle' as const,
+ emailImportResult: null,
+ handleDocumentFileChange: vi.fn(),
+ requestDocumentUpload: vi.fn(),
+ documentActionPendingAction: null,
+ documentUploadFiles: [],
+ documentActionStatus: 'idle' as const,
+ documentActionResult: null,
+ webdavAccountStatus: 'ready' as const,
+ webdavAccounts: [webdavAccount],
+ webdavAccountMap: new Map([[webdavAccount.source_id, { account: webdavAccount, index: 0 }]]),
+ projectFolders: [],
+ selectedRepositoryAssetKey: repositoryAsset.asset_key,
+ setSelectedRepositoryAssetKey: vi.fn(),
+ repositoryAssets: [repositoryAsset],
+ selectedWorkspaceDocument: { state_code: 'ready' },
+ requestDocumentAction,
+ };
+}
+
+describe('DocumentRepositoryTab WebDAV confirmation', () => {
+ let container: HTMLDivElement | null = null;
+ let root: Root | null = null;
+
+ afterEach(async () => {
+ if (root) {
+ await act(async () => root?.unmount());
+ }
+ document.body.style.overflow = '';
+ container?.remove();
+ container = null;
+ root = null;
+ });
+
+ it('keeps focus and interaction inside the modal confirmation and restores the write trigger', async () => {
+ const requestDocumentAction = vi.fn();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render(
);
+ });
+
+ const findButton = (label: string) => Array.from(container?.querySelectorAll('button') ?? [])
+ .find((button) => button.textContent?.includes(label));
+ const writeTrigger = findButton('고객 WebDAV에 문서 쓰기');
+ expect(writeTrigger).toBeDefined();
+ writeTrigger?.focus();
+
+ await act(async () => {
+ writeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+
+ const dialog = container?.querySelector('[role="alertdialog"]');
+ const cancelButton = findButton('취소');
+ const confirmButton = findButton('WebDAV 쓰기 확인');
+ expect(dialog?.getAttribute('aria-modal')).toBe('true');
+ expect(dialog?.parentElement?.className).toContain('fixed inset-0');
+ expect(dialog?.previousElementSibling?.getAttribute('aria-hidden')).toBe('true');
+ expect(document.body.style.overflow).toBe('hidden');
+ expect(document.activeElement).toBe(cancelButton);
+
+ confirmButton?.focus();
+ await act(async () => {
+ confirmButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ });
+ expect(document.activeElement).toBe(cancelButton);
+
+ cancelButton?.focus();
+ await act(async () => {
+ cancelButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
+ });
+ expect(document.activeElement).toBe(confirmButton);
+
+ await act(async () => {
+ confirmButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ });
+ expect(container?.querySelector('[role="alertdialog"]')).toBeNull();
+ expect(document.body.style.overflow).toBe('');
+ expect(document.activeElement).toBe(writeTrigger);
+
+ await act(async () => {
+ writeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+ await act(async () => {
+ findButton('WebDAV 쓰기 확인')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+ expect(requestDocumentAction).toHaveBeenCalledWith('webdav-materialization-intent');
+ expect(document.activeElement).toBe(writeTrigger);
+ });
+
+ it('locks WebDAV account and repository-asset selection while a document action is pending', async () => {
+ const setSelectedWebdavSourceId = vi.fn();
+ const setSelectedRepositoryAssetKey = vi.fn();
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render(
+
,
+ );
+ });
+
+ const webdavAccountButton = Array.from(container.querySelectorAll('button'))
+ .find((button) => button.textContent?.includes('Customer WebDAV'));
+ expect(webdavAccountButton).toBeDefined();
+ expect(webdavAccountButton?.disabled).toBe(true);
+
+ const assetCard = container.querySelector('article[role="button"]');
+ expect(assetCard?.getAttribute('aria-disabled')).toBe('true');
+ await act(async () => {
+ assetCard?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ assetCard?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ assetCard?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
+ });
+
+ expect(setSelectedWebdavSourceId).not.toHaveBeenCalled();
+ expect(setSelectedRepositoryAssetKey).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/data-layout/types.ts b/frontend/src/components/data-layout/types.ts
index 0aada1b80..e7e7dc67c 100644
--- a/frontend/src/components/data-layout/types.ts
+++ b/frontend/src/components/data-layout/types.ts
@@ -42,7 +42,8 @@ export type UniqueThreadIntentResponse = {
export type UniqueThreadStatus = 'idle' | 'loading' | 'success' | 'auth' | 'error';
export type EmailImportStatus = 'idle' | 'loading' | 'success' | 'auth' | 'error';
-export type DocumentActionStatus = 'idle' | 'loading' | 'success' | 'auth' | 'error';
+export type DocumentActionKind = 'upload' | 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent';
+export type DocumentActionStatus = 'idle' | 'loading' | 'success' | 'auth' | 'conflict' | 'invalid' | 'connector_error' | 'error';
export type DataSurfaceStatus = 'loading' | 'ready' | 'error';
@@ -489,6 +490,7 @@ export type DataDocumentActionResponse = {
provenance: 'server-authoritative';
audit_event: string;
message: string;
+ error_code?: string | null;
};
export const duplicateImportCandidates = [
diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts
index f89f5149e..73222a1de 100644
--- a/frontend/tests/e2e/dashboard-branding.spec.ts
+++ b/frontend/tests/e2e/dashboard-branding.spec.ts
@@ -1190,7 +1190,10 @@ test('renders data WebDAV writeback intent and document materialization status',
&& request.method() === 'POST'
);
}, { timeout: 60_000 });
- await page.getByRole('button', { name: 'WebDAV 문서 실행 요청' }).click();
+ await page.getByRole('button', { name: '고객 WebDAV에 문서 쓰기' }).click();
+ const writeConfirmation = page.getByRole('alertdialog', { name: '고객 WebDAV에 문서를 쓰시겠습니까?' });
+ await expect(writeConfirmation).toContainText('이 작업은 고객 원본 저장소를 변경합니다.');
+ await writeConfirmation.getByRole('button', { name: 'WebDAV 쓰기 확인' }).click();
const desktopMaterializationCall = await desktopMaterializationRequest;
const desktopMaterializationHeaders = desktopMaterializationCall.headers();
expectBrowserCookieSession(desktopMaterializationHeaders, expectedNaruonToken);
diff --git a/frontend/tests/e2e/live-smoke.spec.ts b/frontend/tests/e2e/live-smoke.spec.ts
index 79a950cb3..ad1db6887 100644
--- a/frontend/tests/e2e/live-smoke.spec.ts
+++ b/frontend/tests/e2e/live-smoke.spec.ts
@@ -135,7 +135,11 @@ test('live data workspace reaches backend-backed WebDAV and document APIs withou
);
});
await page
- .getByRole('button', { name: 'WebDAV 문서 실행 요청' })
+ .getByRole('button', { name: '고객 WebDAV에 문서 쓰기' })
+ .click();
+ await page
+ .getByRole('alertdialog', { name: '고객 WebDAV에 문서를 쓰시겠습니까?' })
+ .getByRole('button', { name: 'WebDAV 쓰기 확인' })
.click();
const webdavMaterializationResponse = await materializationResponse;
expect(webdavMaterializationResponse.status()).toBeLessThan(400);