Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6508e23
fix(a11y): expose async button busy states
seonghobae Aug 15, 2026
a5c8414
docs(a11y): record async busy-state evidence
seonghobae Aug 15, 2026
c85a871
ci: repair document action busy identity
seonghobae Aug 15, 2026
ffa2f69
ci: verify document action busy identity without new test deps
seonghobae Aug 15, 2026
026ca37
fix(a11y): identify the active document action
github-actions[bot] Aug 15, 2026
cdd96f7
chore(ci): revalidate active document action accessibility
seonghobae Aug 15, 2026
0eff0c4
Merge branch 'develop' into fix/aria-busy-clean-scope
opencode-agent[bot] Aug 15, 2026
537bcdb
test(a11y): avoid dynamic regexp in busy-state evidence
seonghobae Aug 15, 2026
f969010
Merge branch 'develop' into fix/aria-busy-clean-scope
seonghobae Aug 17, 2026
b1a9762
Merge branch 'develop' into fix/aria-busy-clean-scope
seonghobae Aug 17, 2026
87d2d47
Merge branch 'develop' into fix/aria-busy-clean-scope
opencode-agent[bot] Aug 18, 2026
491c52a
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 20, 2026
65ab8cb
test: type document action busy-state props
seonghobae Aug 20, 2026
8b7731d
Merge branch 'develop' into fix/aria-busy-clean-scope
opencode-agent[bot] Aug 23, 2026
eb8af38
Merge branch 'develop' into fix/aria-busy-clean-scope
seonghobae Aug 26, 2026
1b46593
fix(a11y): distinguish review submission from evidence loading
seonghobae Sep 6, 2026
43be57e
fix(projects): stop claiming unsaved evidence notes are persisted
seonghobae Sep 6, 2026
67fed84
Merge protected develop into aria-busy accessibility repair
seonghobae Sep 6, 2026
e11a8e9
fix(session): require explicit authenticated response claims
seonghobae Sep 6, 2026
6abe074
fix(projects): distinguish unavailable sources from empty data
seonghobae Sep 6, 2026
0b28f8e
fix(projects): preserve progress evidence boundaries
seonghobae Sep 7, 2026
517e560
merge: preserve protected attachment repair in project evidence work
seonghobae Sep 7, 2026
9a8c614
test(a11y): distinguish evidence fetch from save busy state
seonghobae Sep 7, 2026
aa6b618
test(a11y): hold document action lock through refresh
seonghobae Sep 7, 2026
0809249
fix(a11y): retain document action lock through refresh
seonghobae Sep 7, 2026
40fcc7c
fix(a11y): scope evidence review busy state to save
seonghobae Sep 7, 2026
010facb
docs(a11y): record busy-state lifecycle invariants
seonghobae Sep 7, 2026
15ed98a
test(a11y): preserve active status on rejected reentry
seonghobae Sep 7, 2026
c45ed60
fix(a11y): guard document reentry before validation
seonghobae Sep 7, 2026
8c9418d
fix(data): 문서 요청과 목록 갱신 상태를 분리
seonghobae Sep 7, 2026
ff6f82c
merge: 원격 접근성 수리와 문서 갱신 보호를 통합
seonghobae Sep 7, 2026
215db67
merge: 원격 재진입 보호 수정을 보존
seonghobae Sep 7, 2026
cc30ba6
fix: 동일 품질 화면의 반복 DOM 읽기를 제거
seonghobae Sep 7, 2026
1b8497f
fix: 프로젝트 smoke 응답의 필수 계약을 복원
seonghobae Sep 7, 2026
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
25 changes: 25 additions & 0 deletions docs/doctoring/async-button-busy-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Async button busy-state accessibility

## Decision

Naruon exposes `aria-busy=true` only while an action control is actively processing its asynchronous operation. The existing `disabled` behavior remains responsible for preventing duplicate activation; `aria-busy` communicates the processing state to the accessibility API rather than replacing the disabled-state contract.

The bounded change applies to the project evidence-review action, repository document actions, and duplicate-thread intent action. It does not claim whole-product accessibility conformance or imply that every statically disabled control is busy.

## Evidence boundary

WAI-ARIA defines `aria-busy` as a state indicating that an element is being modified and that assistive technologies can defer exposing intermediate changes until the operation is complete. The attribute is defined for all elements in the base markup and defaults to `false`. This supports binding `aria-busy` to the same boolean state that represents the in-flight asynchronous action, while leaving ordinary unavailable controls unmarked as busy.

## Verification

Merge readiness is determined only from the unchanged current PR head after repository CI, security, coverage, review, and protected-branch requirements pass. The accessibility attribute itself is not a substitute for rendered assistive-technology testing across supported environments.

## Reference — APA 7th

World Wide Web Consortium. (2026, June 4). *Accessible Rich Internet Applications (WAI-ARIA) 1.3* (Working Draft). https://www.w3.org/TR/2026/WD-wai-aria-1.3-20260604/

## Action identity

A shared loading lock may disable sibling document actions to prevent conflicting writes, but it must not announce every sibling as the operation that is currently processing. Naruon therefore records the initiating document action separately from the shared lock. Only the initiating upload, reparse, embedding-regeneration, HWP-conversion, or WebDAV-materialization button exposes `aria-busy=true`; disabled siblings remain `aria-busy=false`.

The focused server-rendered regression exercises the real button group and fails if a shared boolean again marks every document action busy. Stable `data-document-action` identifiers exist only to bind rendered accessibility evidence to the initiating operation; they do not authorize or execute an action.
23 changes: 17 additions & 6 deletions frontend/src/components/DataLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
UniqueThreadStatus,
EmailImportStatus,
DocumentActionStatus,
ActiveDocumentAction,
DocumentOperation,
DataSurfaceStatus,
DataEvidenceSnapshotResponse,
DataQualitySurfaceResponse,
Expand Down Expand Up @@ -65,6 +67,7 @@ export function DataLayout() {
const [emailImportResult, setEmailImportResult] = useState<EmailFileImportResponse | null>(null);
const [emailImportFiles, setEmailImportFiles] = useState<File[]>([]);
const [documentActionStatus, setDocumentActionStatus] = useState<DocumentActionStatus>('idle');
const [activeDocumentAction, setActiveDocumentAction] = useState<ActiveDocumentAction | null>(null);
const [documentActionResult, setDocumentActionResult] = useState<DataDocumentActionResponse | null>(null);
const [documentUploadFiles, setDocumentUploadFiles] = useState<File[]>([]);
const [dataSurfaceStatus, setDataSurfaceStatus] = useState<DataSurfaceStatus>('loading');
Expand Down Expand Up @@ -239,14 +242,16 @@ export function DataLayout() {
return;
}

const documentType = getDocumentTypeForFile(file);
if (!isTextDocumentUploadType(documentType)) {
setDocumentActionStatus('error');
return;
}

setActiveDocumentAction('upload');
setDocumentActionStatus('loading');
setDocumentActionResult(null);
try {
const documentType = getDocumentTypeForFile(file);
if (!isTextDocumentUploadType(documentType)) {
setDocumentActionStatus('error');
return;
}
const documentContent = await file.text();
const result = await apiClient.post<DataDocumentActionResponse>(
'/api/data/documents',
Expand All @@ -263,11 +268,13 @@ export function DataLayout() {
} catch (error: unknown) {
const status = getApiErrorStatus(error);
setDocumentActionStatus(status === 401 || status === 403 ? 'auth' : 'error');
} finally {
setActiveDocumentAction(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}, [documentUploadFiles, loadDataQualitySurface]);

const requestDocumentAction = useCallback(async (
action: 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent',
action: DocumentOperation,
) => {
const asset = dataQualitySurface?.repository_assets.find((candidate) => (
candidate.asset_key === selectedRepositoryAssetKey
Expand All @@ -284,6 +291,7 @@ export function DataLayout() {
return;
}

setActiveDocumentAction(action);
setDocumentActionStatus('loading');
setDocumentActionResult(null);
try {
Expand All @@ -300,6 +308,8 @@ export function DataLayout() {
} catch (error: unknown) {
const status = getApiErrorStatus(error);
setDocumentActionStatus(status === 401 || status === 403 ? 'auth' : 'error');
} finally {
setActiveDocumentAction(null);
}
}, [
dataQualitySurface,
Expand Down Expand Up @@ -441,6 +451,7 @@ export function DataLayout() {
handleDocumentFileChange={handleDocumentFileChange}
requestDocumentUpload={requestDocumentUpload}
isDocumentActionLoading={isDocumentActionLoading}
activeDocumentAction={activeDocumentAction}
documentUploadFiles={documentUploadFiles}
documentActionStatus={documentActionStatus}
documentActionResult={documentActionResult}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ProjectsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,7 @@ export function ProjectsLayout() {
type="button"
onClick={handleMarkEvidenceReviewed}
disabled={correctionSubmitting || evidenceLoading}
aria-busy={correctionSubmitting || evidenceLoading}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
className="mt-3 min-h-9 w-full rounded-md bg-primary px-3 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground"
>
{correctionSubmitting ? '검토 저장 중' : '문단 근거 검토 저장'}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';

import { DocumentRepositoryTab } from './DocumentRepositoryTab';

const workspaceDocument = {
asset_key: 'workspace-document-1',
asset_type: 'workspace_document',
display_name: '운영 계약.md',
source_label: '로컬 문서',
state_code: 'ready',
detail_text: '검토 가능한 문서 근거',
content_chars: 128,
captured_at: '2026-08-15T09:00:00+09:00',
evidence_source: 'workspace_document',
thread_key: 'thread-1',
provider_write_executed: false,
};

function renderRepository(activeDocumentAction: string | null) {
return renderToStaticMarkup(
<DocumentRepositoryTab
{...({
writebackStatus: 'idle',
writebackResult: null,
requestWebdavWritebackIntent: () => undefined,
isWritebackLoading: false,
canRequestWebdavWriteback: true,
selectedWebdavAccount: {
source_id: 'webdav-1',
display_label: '업무 문서',
writeback_enabled: true,
},
isWebdavSourceLoading: false,
setSelectedWebdavSourceId: () => undefined,
uniqueThreadStatus: 'idle',
uniqueThreadResult: null,
requestUniqueThreadIntent: () => undefined,
isUniqueThreadLoading: false,
connectorEvents: [],
dataSurfaceStatus: 'ready',
dataQualitySurface: null,
embeddingStage: null,
emailRepository: null,
attachmentRepository: null,
handleEmailImportFileChange: () => undefined,
requestEmailFileImport: () => undefined,
isEmailImportLoading: false,
emailImportFiles: [],
emailImportStatus: 'idle',
emailImportResult: null,
handleDocumentFileChange: () => undefined,
requestDocumentUpload: () => undefined,
isDocumentActionLoading: true,
activeDocumentAction,
documentUploadFiles: [],
documentActionStatus: 'loading',
documentActionResult: null,
webdavAccountStatus: 'ready',
webdavAccounts: [{
source_id: 'webdav-1',
display_label: '업무 문서',
writeback_enabled: true,
}],
webdavAccountMap: new Map(),
projectFolders: [],
selectedRepositoryAssetKey: workspaceDocument.asset_key,
setSelectedRepositoryAssetKey: () => undefined,
repositoryAssets: [workspaceDocument],
selectedWorkspaceDocument: workspaceDocument,
requestDocumentAction: () => undefined,
} as never)}
/>,
);
}

function getActionButtonTag(markup: string, action: string) {
const match = markup.match(
new RegExp(`<button[^>]*data-document-action="${action}"[^>]*>`),
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
);
if (!match) {
throw new Error(`missing rendered button for ${action}`);
}
return match[0];
}

describe('DocumentRepositoryTab action busy identity', () => {
it('marks only the initiating document action as busy', () => {
const markup = renderRepository('reparse');

expect(getActionButtonTag(markup, 'reparse')).toContain('aria-busy="true"');
expect(getActionButtonTag(markup, 'embedding-regeneration-intent')).toContain('aria-busy="false"');
expect(getActionButtonTag(markup, 'hwp-conversion-intent')).toContain('aria-busy="false"');
expect(getActionButtonTag(markup, 'webdav-materialization-intent')).toContain('aria-busy="false"');
expect(getActionButtonTag(markup, 'upload')).toContain('aria-busy="false"');
});
});
19 changes: 16 additions & 3 deletions frontend/src/components/data-layout/DocumentRepositoryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
DataQualitySurfaceResponse,
EmailImportStatus,
DocumentActionStatus,
ActiveDocumentAction,
DocumentOperation,
EmailFileImportResponse,
DataDocumentActionResponse,
WebdavAccountStatus,
Expand Down Expand Up @@ -53,6 +55,7 @@ interface DocumentRepositoryTabProps {
handleDocumentFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
requestDocumentUpload: () => void;
isDocumentActionLoading: boolean;
activeDocumentAction: ActiveDocumentAction | null;
documentUploadFiles: File[];
documentActionStatus: DocumentActionStatus;
documentActionResult: DataDocumentActionResponse | null;
Expand All @@ -64,7 +67,7 @@ interface DocumentRepositoryTabProps {
setSelectedRepositoryAssetKey: (key: string | null) => void;
repositoryAssets: any[];
selectedWorkspaceDocument: any;
requestDocumentAction: (action: 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent') => void;
requestDocumentAction: (action: DocumentOperation) => void;
}

export function DocumentRepositoryTab({
Expand All @@ -82,6 +85,7 @@ export function DocumentRepositoryTab({
handleDocumentFileChange,
requestDocumentUpload,
isDocumentActionLoading,
activeDocumentAction,
documentUploadFiles,
documentActionStatus,
documentActionResult,
Expand Down Expand Up @@ -190,8 +194,9 @@ return (
<button
type="button"
onClick={() => void requestDocumentUpload()}
data-document-action="upload"
disabled={isDocumentActionLoading || documentUploadFiles.length === 0}
aria-busy={isDocumentActionLoading}
aria-busy={activeDocumentAction === '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"
>
<Upload className="size-4" />
Expand Down Expand Up @@ -374,7 +379,9 @@ return (
<button
type="button"
onClick={() => void requestDocumentAction('reparse')}
data-document-action="reparse"
disabled={isDocumentActionLoading}
aria-busy={activeDocumentAction === '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"
>
<RefreshCw className="size-4" />
Expand All @@ -383,7 +390,9 @@ return (
<button
type="button"
onClick={() => void requestDocumentAction('embedding-regeneration-intent')}
data-document-action="embedding-regeneration-intent"
disabled={isDocumentActionLoading}
aria-busy={activeDocumentAction === '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"
>
<Database className="size-4" />
Expand All @@ -392,7 +401,9 @@ return (
<button
type="button"
onClick={() => void requestDocumentAction('hwp-conversion-intent')}
data-document-action="hwp-conversion-intent"
disabled={isDocumentActionLoading}
aria-busy={activeDocumentAction === '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"
>
<FileText className="size-4" />
Expand All @@ -401,8 +412,9 @@ return (
<button
type="button"
onClick={() => void requestDocumentAction('webdav-materialization-intent')}
data-document-action="webdav-materialization-intent"
disabled={isDocumentActionLoading || !selectedWebdavAccount || selectedWorkspaceDocument.state_code !== 'ready'}
aria-busy={isDocumentActionLoading}
aria-busy={activeDocumentAction === '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"
>
<Server className="size-4" />
Expand Down Expand Up @@ -518,6 +530,7 @@ return (
type="button"
onClick={() => void requestUniqueThreadIntent()}
disabled={isUniqueThreadLoading}
aria-busy={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"
>
중복 메일 스레드 의도 점검
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/data-layout/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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 DocumentOperation = 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent';
export type ActiveDocumentAction = 'upload' | DocumentOperation;

export type DataSurfaceStatus = 'loading' | 'ready' | 'error';

Expand Down
Loading