Skip to content

feature(web): unify crawler detail/new into split-pane editor (TES-28)#25

Merged
gignac-cha merged 4 commits into
mainfrom
feature/crawler-detail-view-edit-mode
Apr 23, 2026
Merged

feature(web): unify crawler detail/new into split-pane editor (TES-28)#25
gignac-cha merged 4 commits into
mainfrom
feature/crawler-detail-view-edit-mode

Conversation

@gignac-cha
Copy link
Copy Markdown
Owner

@gignac-cha gignac-cha commented Apr 23, 2026

Summary

  • Merges CrawlerDetailPage (view/edit) and CrawlerNewPage (create) into a single CrawlerEditorPage. Route /crawlers/:id handles all modes; id='new' triggers create. /crawlers/new route falls under :id matching.
  • Split-pane layout (Cloudflare/Supabase pattern): form left, test runner right. Test panel toggle only in view mode (default hidden, localStorage-persisted).
  • Hook split into focused modules: use-crawler-editor (orchestrator) over use-crawler-form + use-editor-mode + use-before-unload + pure crawler-form-helpers. Old monolithic state hook removed.

Closes TES-28.

Architecture

File Role
pages/CrawlerEditorPage.tsx Unified page. Mode-driven JSX (Create/View/Edit).
hooks/use-crawler-editor.ts Orchestrator. Composes form, mode, mutations, navigation.
hooks/use-crawler-form.ts Form state + dirty + schema validation.
hooks/use-editor-mode.ts EditorMode state machine.
hooks/use-before-unload.ts Generic browser unload guard.
hooks/crawler-form-helpers.ts Pure utils (typed, no React).
components/DiscardDialog.tsx Radix alertdialog replacement for window.confirm.

Removed: CrawlerDetailPage.tsx, CrawlerNewPage.tsx, CrawlerSubmissionDialog.tsx(+test), old use-crawler-editor-state.ts.

Behavior changes

  • Test panel: hidden by default in view, togglable; always visible in edit/create. localStorage key crawler-editor:test-panel-open.
  • Cancel / Back while dirty: custom DiscardDialog (focus on Keep editing, Esc closes, focus returns to trigger). No more native window.confirm anywhere in the codebase.
  • Save / Submit: aria-disabled + aria-describedby reason ("No changes to save.", "Fix schema errors before saving.", "Name is required.", etc.) so users see why the button is unavailable.
  • Keyboard shortcuts (Cmd/Ctrl+S, +E, +Enter) implemented with React 19.2 useEffectEvent — stable identity, always-fresh closure, no manual Latest Ref pattern. Cmd+E is skipped when focus is in an INPUT|TEXTAREA|SELECT|contenteditable.
  • TopBar wraps on narrow viewports; Title clamps to 20ch under 600px; ShortcutHint hidden under 600px.

Accessibility (WCAG 2.2 AA)

  • 3.3.2 / 1.3.1 — FieldLabel (semantic <label htmlFor>) for Crawler name and URL pattern. Schema textareas use aria-labelledby to their SectionTitle id.
  • 1.4.10 Reflow — removed FormColumn { overflow-y: auto }; page scrolls as one unit, no nested scroll trap at 200% / 400% zoom.
  • 4.1.3 Status Messages — Logs count wrapped in aria-live=\"polite\" aria-atomic=\"true\" so SR users hear count change without focus.
  • 1.4.1 Use of Color — DirtyIndicator carries text "Unsaved" + aria-label; ModeBadge has explicit text.
  • LogOverlay + DiscardDialog rely on Radix Dialog (focus trap + return focus + Esc to close, automatic).

Robustness

  • inFlightRef re-entrancy guard in save() (covers rapid double-click and held Cmd+S).
  • Null-schema defensive guard in deriveFormState (crawler.input_schema ?? {} etc.) — even though DB is NOT NULL, the API boundary is now defensive.
  • markSaved uses identity check to preserve user edits made between save dispatch and response.
  • Mutations (useCreateCrawler, useUpdateCrawler) verified to invalidate the crawlers list query.

Review-worthy concerns / open questions

  • id === 'new' sentinel: chose pragmatism over a dedicated route. UUIDs can't collide with the literal string "new", and React Router's static-vs-dynamic ranking made the dual route awkward to maintain. Open to feedback.
  • Render-phase seeding: use-crawler-editor.ts reseeds form state during render when a new crawler arrives (guarded by seededForCrawlerID !== crawler.id). This is the React-documented "adjusting state when prop changes" pattern; chosen over useEffect to avoid an extra frame of stale data. Worth a second look.
  • Cmd+S in form fields: still active by design (matches browser save muscle memory). Only Cmd+E is suppressed in form elements. Adjust if reviewers prefer stricter behavior.

Test plan

  • pnpm vitest run pages/CrawlerEditorPage — 16/16 pass
  • pnpm lint — clean across web workspace
  • Manual: visit /crawlers/<id> and confirm view-mode read-only, Edit toggles, Save returns to view, Cancel with dirty triggers Discard dialog, test panel toggle persists across reloads
  • Manual: visit /crawlers/new and confirm blank create form, Submit navigates to new detail
  • Manual: 200% browser zoom; verify single-axis scroll, no horizontal trap
  • Manual: keyboard-only — Cmd+S, Cmd+E (view), Cmd+Enter (test panel open)
  • Manual: SR sanity (VoiceOver / NVDA) — disabled Save announces reason, Logs count change announces

🤖 Generated with Claude Code

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 크롤러 생성 및 편집을 위한 통합 에디터 페이지 추가
    • 저장하지 않은 변경사항 표시기 및 확인 대화상자 추가
    • 테스트 패널의 상태를 브라우저에 저장하는 기능 추가
    • 저장, 편집, 실행을 위한 키보드 단축키 지원
  • UI 개선

    • 편집기 높이를 고정 크기로 조정하여 레이아웃 안정성 향상
    • 변경사항 감지 및 페이지 이탈 시 경고 메커니즘 추가
  • 테스트

    • 새 에디터 페이지의 생성, 편집, 조회 흐름에 대한 테스트 추가

gignac-cha and others added 2 commits April 20, 2026 16:55
EditorWrapper with flex+min-height caused Monaco internal height: 100%
to fail resolving (CSS spec — % requires parent explicit height).
Use fixed height: 500px on wrapper and explicit height="100%" on Editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
op read failed with ENOENT because the actual vault item name is
'Service Account - Audio Underview', not 'Service Account Auth Token - Audio Underview'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cca09d7a-ca18-4b08-a453-5aa0f2eec88d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

크롤러 생성과 편집 기능을 단일 CrawlerEditorPage로 통합합니다. /crawlers/new 라우트를 제거하고, /crawlers/:id 라우트가 CrawlerDetailPage 대신 CrawlerEditorPage를 렌더링하도록 변경합니다. 새로운 훅과 헬퍼 함수들을 추가하여 폼 상태 관리, 편집 모드 전환, 미저장 변경사항 감지를 지원합니다.

Changes

Cohort / File(s) Summary
라우팅 업데이트
applications/web/sources/Application.tsx
/crawlers/new 라우트 제거 및 /crawlers/:id 라우트를 CrawlerDetailPage에서 CrawlerEditorPage로 변경. CrawlerEditorPage import 추가.
폼 상태 관리 훅
applications/web/sources/hooks/crawler-form-helpers.ts, applications/web/sources/hooks/use-crawler-form.ts
폼 상태 타입, 스키마 검증, 더티 상태 계산을 위한 헬퍼 함수 추가. 폼 초기화, 리셋, 마크 저장 등의 기능 제공.
에디터 모드 관리
applications/web/sources/hooks/use-editor-mode.ts, applications/web/sources/hooks/use-before-unload.ts
에디터 모드(생성/보기/편집) 상태 관리 및 탭 닫기 전 확인 다이얼로그 트리거를 위한 beforeunload 이벤트 훅 추가.
통합 에디터 훅
applications/web/sources/hooks/use-crawler-editor.ts
크롤러 데이터 로드, 폼 상태 관리, 제출 검증, 미저장 변경 처리, 저장/생성 로직을 통합하는 메인 훅 추가.
에디터 페이지 구현
applications/web/sources/pages/CrawlerEditorPage.tsx, applications/web/sources/pages/CrawlerEditorPage.test.tsx
새로운 통합 에디터 페이지 추가. 생성/편집/보기 모드 지원, 테스트 패널, 미저장 변경 감지, 저장/취소 액션 포함. 테스트 스위트는 뷰/편집 흐름 및 생성 흐름 검증.
페이지 제거
applications/web/sources/pages/CrawlerNewPage.tsx, applications/web/sources/pages/CrawlerDetailPage.tsx
CrawlerNewPageCrawlerDetailPage 컴포넌트 삭제. 기능이 CrawlerEditorPage로 통합됨.
다이얼로그 컴포넌트
applications/web/sources/components/DiscardDialog.tsx
미저장 변경사항 확인을 위한 새로운 DiscardDialog 컴포넌트 추가. Radix UI 기반, 테마 CSS 변수 사용.
레이아웃 조정
applications/web/sources/components/crawlers/CodeEditorPanel.tsx
에디터 컨테이너 높이를 고정 500px로 변경. 모나코 에디터가 전체 높이를 사용하도록 설정.
제출 다이얼로그 제거
applications/web/sources/components/crawlers/CrawlerSubmissionDialog.tsx, applications/web/sources/components/crawlers/CrawlerSubmissionDialog.test.tsx
CrawlerSubmissionDialog 컴포넌트 및 테스트 스위트 삭제. 기능이 CrawlerEditorPage에 통합됨.
환경 설정
tools/environment-generator/sources/setup.ts
1Password 참조 경로 업데이트.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Editor as CrawlerEditorPage
    participant Hook as useCrawlerEditor
    participant Form as useCrawlerForm
    participant API as API
    participant Dialog as DiscardDialog

    User->>Editor: 페이지 진입 (생성 또는 편집)
    Editor->>Hook: useCrawlerEditor(id) 호출
    
    alt 편집 모드 (id 존재)
        Hook->>API: 크롤러 데이터 로드
        API-->>Hook: 크롤러 데이터 반환
        Hook->>Form: deriveFormState로 폼 초기화
    else 생성 모드 (id 없음)
        Hook->>Form: BLANK_FORM으로 초기화
    end
    
    Hook-->>Editor: 편집 상태, 폼 필드, 저장 함수 제공
    Editor-->>User: 뷰 모드로 렌더링
    
    User->>Editor: 편집 버튼 클릭
    Editor->>Hook: enterEdit() 호출
    Hook-->>Editor: 편집 모드 활성화
    Editor-->>User: 편집 가능 UI 렌더링
    
    User->>Editor: 폼 필드 수정
    Editor->>Form: 폼 상태 업데이트
    Form->>Hook: isDirty 계산
    Hook->>Hook: useBeforeUnload(isDirty) 활성화
    
    alt 저장 클릭
        User->>Editor: 저장 버튼 클릭
        Editor->>Hook: save() 호출
        Hook->>Form: 스키마 유효성 검사
        Hook->>API: createCrawler/updateCrawler 호출
        API-->>Hook: 성공 응답
        Hook-->>Editor: 저장 완료
        Editor-->>User: 성공 토스트 표시
    else 취소 클릭 (미저장 변경 있음)
        User->>Editor: 취소 버튼 클릭
        Editor->>Dialog: DiscardDialog 열기
        Dialog-->>User: 확인 다이얼로그 표시
        User->>Dialog: 폐기 확인
        Dialog->>Hook: discard() 호출
        Hook->>Form: 폼 리셋
        Hook-->>Editor: 뷰 모드로 전환
        Editor-->>User: 변경사항 폐기됨
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 한 페이지로 통합하니,
생성과 편집이 춤을 춘다!
미저장 변경을 감시하고,
다이얼로그로 사용자를 지킨다.
더 깔끔한 여정, 더 나은 경험!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 풀 리퀘스트 제목은 주요 변경 사항을 명확하게 요약합니다. CrawlerDetailPage와 CrawlerNewPage를 단일 CrawlerEditorPage로 통합하는 것이 핵심 변경이며, 제목에서 'unify crawler detail/new into split-pane editor'로 정확히 설명합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/crawler-detail-view-edit-mode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Replaces CrawlerDetailPage and CrawlerNewPage with a single CrawlerEditorPage
that handles view/edit/create on /crawlers/:id (id='new' = create mode),
following the Cloudflare/Supabase split-pane editor pattern.

- /crawlers/:id route renders CrawlerEditorPage in all three modes
- Split-pane layout: form left, test runner right; toggleable in view (localStorage), always on in edit/create
- Hook split: use-crawler-editor (orchestrator) over use-crawler-form, use-editor-mode, use-before-unload, plus pure crawler-form-helpers
- Custom DiscardDialog (Radix alertdialog) replaces every window.confirm in the codebase
- FieldLabel + htmlFor on name/url_pattern, aria-labelledby on schemas, aria-disabled + aria-describedby with computed reason on Save
- aria-live count on Logs button, FormColumn no longer scroll-traps at zoom (WCAG 1.4.10)
- Keyboard shortcuts (Cmd+S/E/Enter) via React 19.2 useEffectEvent; Cmd+E skipped in form fields
- inFlightRef guards save() against double-submit; 16/16 tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gignac-cha gignac-cha force-pushed the feature/crawler-detail-view-edit-mode branch from efef358 to 98c1e33 Compare April 23, 2026 08:32
@gignac-cha
Copy link
Copy Markdown
Owner Author

@coderabbitai review

다음 영역을 중점적으로 검토 부탁드립니다.

아키텍처 / hook 분리

  • 신규 5개 hook (use-crawler-editor.ts orchestrator + use-crawler-form.ts, use-editor-mode.ts, use-before-unload.ts, crawler-form-helpers.ts). 책임 경계가 깔끔한지, 누수 추상화나 hook 간 state 중복 없는지.
  • use-crawler-editor.ts의 render-phase seeding 패턴 (if (crawler && seededForCrawlerID !== crawler.id) { ... setState ... }). React 공식의 "adjusting state when prop changes" 패턴인데 StrictMode double-invoke 환경에서 가드가 충분한지.
  • id === 'new' sentinel로 create 모드 판정. 실용적인지, 코드 스멜인지.

React 19.2 패턴

  • 키보드 단축키에 useEffectEvent 사용 (onSaveShortcut, onEditShortcut, onRunShortcut). 안정적 identity + 항상 fresh closure. 공식 의미와 일치하는지.
  • save()inFlightRef 재진입 가드 — try 안에서 set, finally에서 reset. create-then-navigate 등 모든 종료 경로에서 정확히 동작하는지.

접근성

  • Save/Submit에 native disabled 대신 aria-disabled + aria-describedby (hook이 disabledReason 계산). onClick guard if (canSubmit && !isSaving) save(). 이게 disabled 역할을 완전히 대체하는지.
  • name / url_patternFieldLabel (styled.label) + htmlFor. Schema는 aria-labelledbySectionTitle id 가리킴. 읽기 전용 MetaLabelstyled.span (Type/Created/Updated는 form control 없으니 의도된 선택). 문제 없는지.
  • DiscardDialog는 Radix Dialog + role="alertdialog" override (별도 alert-dialog primitive 미사용). 기본 포커스는 "Keep editing". WCAG 충족하는지.
  • Logs 버튼 카운트를 <span aria-live="polite" aria-atomic="true">로 감쌈. focus 없이도 SR이 카운트 변화 announce 하는지.

UX / 동작

  • Test panel 토글은 view 모드에서만 노출. 기본 숨김, localStorage (crawler-editor:test-panel-open)에 저장. mode === 'view' ? viewShowTest : true 규칙.
  • TopBar / TopBarLeft / TopBarRight 모두 flex-wrap: wrap. Title 600px 이하에서 20ch clamp. ShortcutHint 600px 이하 숨김.
  • Dirty 상태 Cancel/Back은 DiscardDialog 열고 pendingDiscardActionRef로 지연된 action 저장. ref가 새거나 두 번 실행되는 경로 있는지.

견고성

  • deriveFormStatecrawler.input_schema ?? {} / crawler.output_schema ?? {} 가드 — DB는 NOT NULL이지만 API 경계에서 방어. 적정한지, 노이즈인지.
  • markSaved는 identity check (current === submitted ? next : current)로 in-flight save 중 유저 편집 보존. race 커버 검증.
  • Cmd+S는 form 필드 안에서도 의도적으로 활성 (브라우저 save 머슬 메모리), Cmd+EisFromFormElement(event) (INPUT/TEXTAREA/SELECT/contenteditable)로 skip. 적절한 경계인지.

이번 PR scope 외 (블로커로 표시 X)

  • Logout 버튼이 dirty guard 우회 (router-level, 별도 이슈)
  • id === 'new' sentinel 대신 dedicated /crawlers/new route alias로 분리
  • 페이지 간 styled primitives 추출 (지연)

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and read all relevant files
echo "=== FINDING FILES ==="
fd -t f -e ts -e tsx . --full-path | grep -E "(crawler-editor|crawler-form|editor-mode|before-unload|crawler-form-helpers|CrawlerEditorPage|DiscardDialog)" | sort

echo ""
echo "=== use-crawler-editor ==="
fd -t f "use-crawler-editor" --exec cat {}

echo ""
echo "=== use-crawler-form ==="
fd -t f "use-crawler-form" --exec cat {}

echo ""
echo "=== use-editor-mode ==="
fd -t f "use-editor-mode" --exec cat {}

echo ""
echo "=== use-before-unload ==="
fd -t f "use-before-unload" --exec cat {}

echo ""
echo "=== crawler-form-helpers ==="
fd -t f "crawler-form-helpers" --exec cat {}

echo ""
echo "=== CrawlerEditorPage ==="
fd -t f "CrawlerEditorPage" --exec cat {}

echo ""
echo "=== DiscardDialog ==="
fd -t f "DiscardDialog" --exec cat {}

Length of output: 56544


@gignac-cha 면밀히 검토했습니다. 전반적으로 아키텍처 설계가 탄탄하고, 특히 hook 경계 분리와 React 19 패턴 적용이 잘 되어 있습니다. 아래에 영역별 상세 분석을 정리합니다.


🏗️ 아키텍처 / Hook 분리

결론: 깔끔합니다. 누수 추상화나 state 중복 없음.

  • crawler-form-helpers.ts의 순수 유틸 분리(deriveFormState, computeIsDirty, tryParseSchema)가 useCrawlerForm의 테스트 가능성을 높이는 좋은 구조입니다.
  • Hook 간 state 중복 없음: form/pristineuse-crawler-form, modeuse-editor-mode, seededForCrawlerIDuse-crawler-editor에만 존재합니다.
  • discardDialogOpenpendingDiscardActionRef를 orchestrator에 두는 것은 올바른 판단입니다. Dirty guard 로직과 분리하면 오히려 응집도가 낮아집니다.

소소한 관찰: disabledReason 계산 로직이 orchestrator에 inline으로 작성되어 있는데, 지금은 괜찮지만 조건 분기가 늘어나면 computeDisabledReason(form, isCreateMode, isDirty, hasSchemaError, ...) 헬퍼로 추출을 고려해볼 수 있습니다.


🔄 Render-phase Seeding

결론: StrictMode 가드 충분합니다.

if (!isCreateMode && crawler && seededForCrawlerID !== crawler.id) {
  setSeededForCrawlerID(crawler.id);
  formState.resetFromCrawler(crawler);
  modeState.exitEdit();
}

React Strict Mode의 double-invoke 시나리오:

  • 1차 렌더: seededForCrawlerID(undefined) !== crawler.id → 5개 setState 호출 (setSeededForCrawlerID + setForm + setPristine + setSchemaErrors + setIsEditMode)
  • 2차 렌더 (Strict re-invoke): React가 1차에서 set한 값을 반영하여 seededForCrawlerID === crawler.id → 조건 진입 안 함 ✓

단, 단일 render에서 여러 setState를 호출하므로 추가 렌더가 발생합니다. React 공식 문서의 "adjusting state during rendering" 패턴에서 권장하는 대로 early return은 없지만, 이미 seededForCrawlerID 체크로 무한 루프를 막고 있으므로 문제없습니다.


⚛️ React 19.2 패턴

useEffectEvent — 올바른 사용입니다 ✓

const onSaveShortcut = useEffectEvent(() => {
  if (!isEditable) return false;
  if (canSubmit && !isSaving) save();
  return true;
});

useEffect(() => {
  const handler = (event: KeyboardEvent) => { ... };
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
}, []); // ← 빈 deps
  • Effect는 mount 시 한 번만 등록 (stable identity)
  • onSaveShortcut 내부에서 canSubmit, isSaving, save의 최신 closure를 항상 읽음
  • useEffectEvent 함수를 deps 배열에 넣지 않는 것이 공식 의미와 일치합니다. React 19에서 정식 stable API입니다. ✓

inFlightRef 재진입 가드 — 모든 종료 경로에서 안전합니다 ✓

// update path
if (!crawler) return;      // ← inFlightRef 미설정 상태에서 return (async 없음, 정상)
inFlightRef.current = true;
try { ... await updateCrawler(...); } 
catch { ... } 
finally { inFlightRef.current = false; }
  • if (!crawler) returninFlightRef를 건드리지 않고 return. async 작업이 없으므로 올바릅니다.
  • create path의 navigate(...)finally { inFlightRef.current = false } 실행 — 컴포넌트가 unmount 중일 수 있으나 ref 할당은 무해합니다.
  • 유효성 검사 early return들(schema 파싱 실패 등)도 inFlightRef 미설정 상태에서 return. 동기 코드이므로 문제없습니다. ✓

♿️ 접근성

aria-disabled 패턴 — 완전히 대체합니다 ✓

  • Button이 실제로 disabled되지 않아 탭 포커스 유지 → SR 사용자가 aria-describedby를 통해 비활성 이유를 들을 수 있습니다.
  • onClick guard와 Cmd+S shortcut handler 모두 canSubmit && !isSaving 체크 일치. ✓
  • id="crawler-submit-reason" / id="crawler-save-reason" — 모드가 mutual exclusive이므로 동시에 렌더되지 않아 ID 충돌 없음. ✓

DiscardDialog — WCAG 충족합니다 ✓

  • role="alertdialog" override는 Radix Dialog.Contentaria-modal="true" 및 focus trap 기능을 유지합니다.
  • Dialog.Title + Dialog.Description 사용 → Radix가 자동으로 aria-labelledby / aria-describedby를 dialog에 연결합니다.
  • autoFocus가 안전한 액션("Keep editing") 버튼에 있는 것은 WCAG 3.2.2 On Input 관점에서 올바릅니다.

⚠️ 이슈: aria-live in <button>

<GhostButton aria-label="Open logs">
  Logs (
  <span aria-live="polite" aria-atomic="true">{executionLogs.length}</span>
  )
</GhostButton>

aria-live region을 interactive element 내부에 두는 것은 NVDA/JAWS/VoiceOver 간 동작이 일관되지 않습니다. 또한 버튼의 accessible name이 aria-label="Open logs"로 고정되어 카운트 변화가 버튼 이름에 반영되지 않습니다.

권장 수정:

{/* 버튼 외부에 sr-only live region */}
<span
  aria-live="polite"
  aria-atomic="true"
  style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}
>
  {executionLogs.length > 0 ? `${executionLogs.length} log entries` : ''}
</span>

<GhostButton
  onClick={() => setIsLogOverlayOpen(true)}
  aria-label={`Open logs (${executionLogs.length})`}
>
  Logs ({executionLogs.length})
</GhostButton>

🖥️ UX / 동작

Test panel 로직 ✓

const showTest = mode === 'view' ? viewShowTest : true;

간결하고 의도가 명확합니다.

pendingDiscardActionRef 누수/이중 실행 없음 ✓

  • onConfirm: ref 클리어 → setDiscardDialogOpen(false)action?.() 순서. Radix가 이후 onOpenChange(false) 호출하지만 ref가 이미 null이어서 이중 실행 없음.
  • KeepButton 클릭 → onOpenChange(false) → ref 클리어. ✓
  • Esc 키 → Radix가 onOpenChange(false) 호출 → ref 클리어. ✓

🔧 견고성

deriveFormState null 가드 — 적정합니다 ✓

input_schema: stringifySchema(crawler.input_schema ?? {}),

DB NOT NULL 제약이 있어도 API 경계에서 null이 올 수 있는 상황(마이그레이션 전 레거시 데이터, API 버전 차이 등)을 방어합니다. 노이즈가 아니라 적절한 defensive coding입니다.

markSaved race condition 커버 ✓

setForm((current) => (current === submitted ? next : current));
setPristine(next);
  • submittedsave() 호출 시점의 formState.form reference를 캡처합니다.
  • save 중 사용자 편집 발생 → setForm({ ...form, field: newValue })새 객체 생성 → current !== submitted → 사용자 편집 보존. ✓
  • setPristine(next)는 서버 응답 기준으로 항상 갱신 → 이후 dirty 계산이 "서버 기준 변경분"을 올바르게 반영. ✓

Cmd+S / Cmd+E 경계 — 의도에 부합합니다 ✓

PR 설명대로 Cmd+S의 "브라우저 저장 대화상자 대체" 경험은 유효한 UX 선택입니다. Cmd+EisFromFormElement 체크로 적절히 필터링됩니다.


🐛 추가 관찰: 크롤러 전환 시 실행 로그 미초기화

executionLogs는 페이지 컴포넌트 local state이며 크롤러 ID가 바뀌어도 초기화되지 않습니다. 사용자가 크롤러 A의 테스트 실행 후 크롤러 B로 이동하면 A의 로그가 남아있을 수 있습니다.

// use-crawler-editor.ts seeding 블록에서 로그 초기화 콜백을 호출하거나,
// CrawlerEditorPage에서 crawler?.id에 의존하는 useEffect로 처리 가능:
useEffect(() => {
  setExecutionLogs([]);
}, [crawler?.id]);

현재 스코프 외 이슈일 수 있지만, 특히 LogOverlay가 열려있는 상태에서 전환 시 혼란을 줄 수 있어 남겨둡니다.


요약 테이블

영역 상태 비고
Hook 책임 경계 ✅ 깔끔 disabledReason 헬퍼 추출 여지 (minor)
Render-phase seeding ✅ StrictMode 안전 추가 렌더 1회 (React 공식 패턴)
id === 'new' sentinel ✅ 실용적 UUID 충돌 불가, 관용적 패턴
useEffectEvent ✅ 올바른 사용 React 19 stable API
inFlightRef 가드 ✅ 모든 경로 안전
aria-disabled disabled 완전 대체 SR 경험 오히려 향상
FieldLabel / MetaLabel ✅ 의도된 설계
DiscardDialog ✅ WCAG 충족
aria-live in <button> ⚠️ AT 호환성 이슈 버튼 외부로 이동 권장
pendingDiscardActionRef ✅ 이중 실행 없음
markSaved race ✅ 커버됨
실행 로그 미초기화 ⚠️ 잠재적 UX 혼란 크롤러 전환 시
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
applications/web/sources/pages/CrawlerEditorPage.test.tsx (1)

82-146: 🧹 Nitpick | 🔵 Trivial

테스트 간 localStorage 정리를 beforeEach로 일원화 권장

TEST_PANEL_STORAGE_KEY를 일부 테스트(예: Line 84, 108, 126)에서만 제거하고 나머지 테스트(특히 dirty/Cancel/Save 관련 다수와 create-flow 두 테스트)에서는 제거하지 않습니다. 현재는 edit/create 모드가 테스트 패널을 강제로 켜기 때문에 단언이 모두 통과하지만, 향후 뷰 모드 기본값이나 저장 키 의미가 바뀌면 실행 순서에 따라 조용히 flaky해질 수 있습니다.

♻️ 제안 수정
 describe('CrawlerEditorPage — view/edit flows', () => {
+  beforeEach(() => {
+    window.localStorage.removeItem(TEST_PANEL_STORAGE_KEY);
+  });
+
   test('renders crawler details read-only by default with test panel hidden', async () => {
-    window.localStorage.removeItem(TEST_PANEL_STORAGE_KEY);
-
     worker.use(

create-flow describe에도 동일한 beforeEach를 추가하면 전체 스위트가 테스트 순서 독립이 됩니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/pages/CrawlerEditorPage.test.tsx` around lines 82 -
146, Add a beforeEach that clears
window.localStorage.removeItem(TEST_PANEL_STORAGE_KEY) inside the describe block
for "CrawlerEditorPage — view/edit flows" (and mirror the same beforeEach in the
separate "create-flow" describe) so each test starts with a clean
TEST_PANEL_STORAGE_KEY state; this replaces the scattered manual removals in
individual tests and ensures tests referencing TEST_PANEL_STORAGE_KEY are
order-independent.
applications/web/sources/components/crawlers/CodeEditorPanel.tsx (1)

41-46: 🧹 Nitpick | 🔵 Trivial

고정 500px 높이가 split-pane 환경에서 확장성/반응성을 떨어뜨립니다.

Monaco가 flex/min-height: 0 컨테이너에서 레이아웃을 잡지 못하는 이슈를 고정 높이로 우회한 것으로 보입니다. 다만 이 패널은 새 split-pane 에디터의 좌측 패널에서 사용되므로,

  • 뷰포트가 클 때: 남는 세로 공간을 활용하지 못하고 500px에 고정됩니다.
  • 뷰포트가 작을 때(모바일/태블릿): 500px이 페이지 전체 세로 스크롤을 유발할 수 있습니다.

min-height로 바꾸고 부모가 높이를 제공하는 구조를 유지하면 반응성을 회복할 수 있습니다.

♻️ 제안 수정
 const EditorWrapper = styled.div`
-  height: 500px;
+  min-height: 500px;
+  flex: 1;
   border: 1px solid var(--border-subtle);
   border-radius: 8px;
   overflow: hidden;
 `;

부모 Containerflex: 1; min-height: 0;를 유지하고 있으므로 Editorheight="100%" + automaticLayout: true 조합으로 Monaco가 올바르게 리사이즈됩니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/components/crawlers/CodeEditorPanel.tsx` around
lines 41 - 46, The EditorWrapper currently uses a fixed height (height: 500px)
which breaks responsiveness; change EditorWrapper (styled.div) to avoid a fixed
height (use min-height: 0 and/or height: 100% instead) so the parent Container
(ensure it has flex: 1; min-height: 0) can provide sizing, and make sure the
Editor component is rendered with height="100%" and Monaco's automaticLayout:
true so the editor resizes correctly in split-pane layouts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@applications/web/sources/hooks/use-before-unload.ts`:
- Around line 6-8: The beforeunload handler currently only calls
event.preventDefault(), which won't trigger the confirmation dialog in some
browsers; update the handler function (the const handler in
use-before-unload.ts) to set both event.preventDefault() AND assign
event.returnValue = '' (or a descriptive string) to ensure compatibility with
Chrome <119 and Safari, keeping the same cleanup/unsubscribe logic.

In `@applications/web/sources/hooks/use-crawler-editor.ts`:
- Around line 179-183: markSaved currently doesn't indicate whether the
submitted changes were actually applied, so calling modeState.exitEdit()
unconditionally can hide newly made edits; change markSaved to return a boolean
(true if it accepted/applied the submittedForm vs next identity check) and in
this block capture that result from markSaved(submittedForm, next) and only call
modeState.exitEdit() when it returns true; this involves updating the markSaved
signature and its callers and keeping the existing calls to
deriveFormState(updated) and updateCrawler(payload) as-is.
- Around line 22-34: When isCreateMode changes the form and editor mode must be
reset; add a useEffect in the useCrawlerForm hook that listens to [isCreateMode]
and calls the hook's reset logic (e.g., setForm(...)/setPristine(...) or the
hook's existing reset method) to clear previous crawler data, and add a
useEffect in useEditorMode that listens to [isCreateMode] and sets edit mode off
(e.g., setIsEditMode(false)). This ensures useCrawlerForm and useEditorMode
react to isCreateMode changes instead of only initializing once.

In `@applications/web/sources/pages/CrawlerEditorPage.tsx`:
- Around line 479-510: When the active crawler id changes, clear per-crawler UI
and runner state by adding an effect that watches the crawler id and on change
calls setTestURL(''), setExecutionLogs([]), setIsLogOverlayOpen(false) and
resets the test panel visibility (via setViewShowTest) as appropriate, and also
invoke the code runner reset method returned from useCrawlerCodeRunner (call its
reset() function) so runTest/runStatus/runResult/runError are cleared; implement
this effect near the existing state/hooks that reference testURL, executionLogs,
viewShowTest, handleLog and useCrawlerCodeRunner.
- Around line 787-790: The live region for log count should be moved out of the
interactive GhostButton to avoid unreliable AT notifications; keep the button
label showing the count (e.g., "Logs (N)") but remove aria-live/aria-atomic from
inside GhostButton, and add a separate visually-hidden element (e.g., a span
with aria-live="polite" aria-atomic="true" and hidden/status-only styling) that
updates with executionLogs.length; update the GhostButton usage (onClick={() =>
setIsLogOverlayOpen(true)}) to no longer contain aria-live attributes and ensure
the new hidden live-region element is updated whenever executionLogs changes.

In `@tools/environment-generator/sources/setup.ts`:
- Line 8: Update the README.md to document the exact 1Password item name and
required field so the environment generator can find it: state that developers
must create a 1Password Personal item named "Service Account - Audio Underview"
and include a field named "credential" (matching the op://Personal/Service
Account - Audio Underview/credential reference in setup.ts); add a short note
about where this value is used by the setup script and an example of the
item/field pair to prevent lookup failures.

---

Outside diff comments:
In `@applications/web/sources/components/crawlers/CodeEditorPanel.tsx`:
- Around line 41-46: The EditorWrapper currently uses a fixed height (height:
500px) which breaks responsiveness; change EditorWrapper (styled.div) to avoid a
fixed height (use min-height: 0 and/or height: 100% instead) so the parent
Container (ensure it has flex: 1; min-height: 0) can provide sizing, and make
sure the Editor component is rendered with height="100%" and Monaco's
automaticLayout: true so the editor resizes correctly in split-pane layouts.

In `@applications/web/sources/pages/CrawlerEditorPage.test.tsx`:
- Around line 82-146: Add a beforeEach that clears
window.localStorage.removeItem(TEST_PANEL_STORAGE_KEY) inside the describe block
for "CrawlerEditorPage — view/edit flows" (and mirror the same beforeEach in the
separate "create-flow" describe) so each test starts with a clean
TEST_PANEL_STORAGE_KEY state; this replaces the scattered manual removals in
individual tests and ensures tests referencing TEST_PANEL_STORAGE_KEY are
order-independent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e4199a9b-8430-441a-a57f-e705b0eb89e6

📥 Commits

Reviewing files that changed from the base of the PR and between d7a4345 and 98c1e33.

📒 Files selected for processing (15)
  • applications/web/sources/Application.tsx
  • applications/web/sources/components/DiscardDialog.tsx
  • applications/web/sources/components/crawlers/CodeEditorPanel.tsx
  • applications/web/sources/components/crawlers/CrawlerSubmissionDialog.test.tsx
  • applications/web/sources/components/crawlers/CrawlerSubmissionDialog.tsx
  • applications/web/sources/hooks/crawler-form-helpers.ts
  • applications/web/sources/hooks/use-before-unload.ts
  • applications/web/sources/hooks/use-crawler-editor.ts
  • applications/web/sources/hooks/use-crawler-form.ts
  • applications/web/sources/hooks/use-editor-mode.ts
  • applications/web/sources/pages/CrawlerDetailPage.tsx
  • applications/web/sources/pages/CrawlerEditorPage.test.tsx
  • applications/web/sources/pages/CrawlerEditorPage.tsx
  • applications/web/sources/pages/CrawlerNewPage.tsx
  • tools/environment-generator/sources/setup.ts
💤 Files with no reviewable changes (4)
  • applications/web/sources/components/crawlers/CrawlerSubmissionDialog.test.tsx
  • applications/web/sources/components/crawlers/CrawlerSubmissionDialog.tsx
  • applications/web/sources/pages/CrawlerDetailPage.tsx
  • applications/web/sources/pages/CrawlerNewPage.tsx

Comment on lines +6 to +8
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does Chrome, Safari and Firefox in 2025 require setting event.returnValue on beforeunload, or is preventDefault() alone sufficient across major browsers?

💡 Result:

No, preventDefault alone is not sufficient across major browsers like Chrome, Safari, and Firefox in 2025. Both preventDefault and setting event.returnValue are recommended for full compatibility. Modern best practice (post-Chrome 119, current versions of all browsers) is to call event.preventDefault to trigger the confirmation dialog. This is fully supported in Chrome 119+, Firefox 6+, Safari 11+. However, MDN explicitly advises also setting event.returnValue = true for legacy support, noting "e.g. Chrome/Edge < 119". While pre-119 versions are outdated by 2025, some users or enterprise environments may still use them. CanIUse confirms returnValue support is widespread, but preventDefault activation became standard later. The recommended code from MDN (updated 2025): const beforeUnloadHandler = (event) => { event.preventDefault; // Recommended for modern browsers event.returnValue = true; // Legacy support }; Sources confirm this dual approach ensures the dialog triggers reliably across Chrome, Safari, and Firefox, including any lingering legacy cases.

Citations:


레거시 브라우저 호환성: event.returnValue 설정 필수

preventDefault() 만으로는 Chrome 119 이전 버전과 일부 Safari에서 beforeunload 확인 프롬프트가 나타나지 않습니다. 저장되지 않은 변경사항 보호라는 이 훅의 목적을 확실히 하려면 두 방식을 모두 적용해야 합니다.

제안 수정
     const handler = (event: BeforeUnloadEvent) => {
       event.preventDefault();
+      event.returnValue = '';
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/hooks/use-before-unload.ts` around lines 6 - 8, The
beforeunload handler currently only calls event.preventDefault(), which won't
trigger the confirmation dialog in some browsers; update the handler function
(the const handler in use-before-unload.ts) to set both event.preventDefault()
AND assign event.returnValue = '' (or a descriptive string) to ensure
compatibility with Chrome <119 and Safari, keeping the same cleanup/unsubscribe
logic.

Comment on lines +22 to +34
const formState = useCrawlerForm(isCreateMode);
const modeState = useEditorMode(isCreateMode);

const [seededForCrawlerID, setSeededForCrawlerID] = useState<string | undefined>(undefined);
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const pendingDiscardActionRef = useRef<(() => void) | null>(null);
const inFlightRef = useRef(false);

if (!isCreateMode && crawler && seededForCrawlerID !== crawler.id) {
setSeededForCrawlerID(crawler.id);
formState.resetFromCrawler(crawler);
modeState.exitEdit();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -i 'use-(crawler-form|editor-mode)\.ts$' --exec sh -c '
  printf "\n=== %s ===\n" "$1"
  sed -n "1,240p" "$1"
' sh {}

Repository: gignac-cha/audio-underview

Length of output: 3012


isCreateMode 변경 시 폼 및 모드 상태를 초기화하는 effect 훅 추가 필요

useCrawlerFormuseEditorMode 모두 isCreateMode 기반으로 초기 상태만 설정하고, 해당 prop 변경에 반응하는 effect 훅이 없습니다. 같은 컴포넌트에서 기존 크롤러 → /crawlers/new 이동 시 두 훅의 상태가 유지되어, 이전 폼 데이터와 edit 모드가 create 화면에 남게 됩니다.

useCrawlerFormuseEffect(() => { setForm(...); setPristine(...); }, [isCreateMode])를 추가하고, useEditorModeuseEffect(() => { setIsEditMode(false); }, [isCreateMode])를 추가하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/hooks/use-crawler-editor.ts` around lines 22 - 34,
When isCreateMode changes the form and editor mode must be reset; add a
useEffect in the useCrawlerForm hook that listens to [isCreateMode] and calls
the hook's reset logic (e.g., setForm(...)/setPristine(...) or the hook's
existing reset method) to clear previous crawler data, and add a useEffect in
useEditorMode that listens to [isCreateMode] and sets edit mode off (e.g.,
setIsEditMode(false)). This ensures useCrawlerForm and useEditorMode react to
isCreateMode changes instead of only initializing once.

Comment on lines +179 to +183
const submittedForm = formState.form;
const updated = await updateCrawler(payload);
const next = deriveFormState(updated);
formState.markSaved(submittedForm, next);
modeState.exitEdit();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

저장 중 추가 편집이 있으면 edit 모드를 유지하세요.

markSaved가 제출 당시 form이 아닌 최신 form을 보존하더라도, 바로 exitEdit()를 호출하면 보존된 미저장 변경사항이 view 모드에 숨겨질 수 있습니다. markSaved가 실제 적용 여부를 반환하게 하고, 적용된 경우에만 view 모드로 전환하세요.

🐛 제안 수정
       const submittedForm = formState.form;
       const updated = await updateCrawler(payload);
       const next = deriveFormState(updated);
-      formState.markSaved(submittedForm, next);
-      modeState.exitEdit();
+      const didMarkSubmittedFormAsSaved = formState.markSaved(submittedForm, next);
+      if (didMarkSubmittedFormAsSaved) {
+        modeState.exitEdit();
+      }
       showToast('Saved', `Crawler "${trimmedName}" has been updated.`, 'success');

markSaved가 현재 void를 반환한다면, identity check 성공 여부를 boolean으로 반환하도록 함께 변경해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/hooks/use-crawler-editor.ts` around lines 179 - 183,
markSaved currently doesn't indicate whether the submitted changes were actually
applied, so calling modeState.exitEdit() unconditionally can hide newly made
edits; change markSaved to return a boolean (true if it accepted/applied the
submittedForm vs next identity check) and in this block capture that result from
markSaved(submittedForm, next) and only call modeState.exitEdit() when it
returns true; this involves updating the markSaved signature and its callers and
keeping the existing calls to deriveFormState(updated) and
updateCrawler(payload) as-is.

Comment on lines +479 to +510
const [testURL, setTestURL] = useState('');
const [executionLogs, setExecutionLogs] = useState<LogEntry[]>([]);
const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);

const [viewShowTest, setViewShowTest] = useState<boolean>(() => {
try {
return window.localStorage.getItem(TEST_PANEL_STORAGE_KEY) === 'open';
} catch {
return false;
}
});

const showTest = mode === 'view' ? viewShowTest : true;

const handleToggleTest = () => {
if (mode !== 'view') return;
setViewShowTest((previous) => {
const next = !previous;
try {
window.localStorage.setItem(TEST_PANEL_STORAGE_KEY, next ? 'open' : 'closed');
} catch {
/* ignore */
}
return next;
});
};

const handleLog = useCallback((entry: LogEntry) => {
setExecutionLogs((prev) => [...prev, entry]);
}, []);

const { runTest, status: runStatus, result: runResult, error: runError } = useCrawlerCodeRunner({ onLog: handleLog });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

크롤러 전환 시 테스트 실행 상태를 초기화하세요.

같은 CrawlerEditorPage 인스턴스에서 id만 바뀌면 이전 크롤러의 로그, 결과, 에러가 새 크롤러 화면에 남을 수 있습니다. useCrawlerCodeRunner.reset()까지 함께 호출해 결과 패널도 비워 주세요.

🐛 제안 수정
-  const { runTest, status: runStatus, result: runResult, error: runError } = useCrawlerCodeRunner({ onLog: handleLog });
+  const {
+    runTest,
+    status: runStatus,
+    result: runResult,
+    error: runError,
+    reset: resetRunState,
+  } = useCrawlerCodeRunner({ onLog: handleLog });
   const isRunning = runStatus === 'running';
+
+  useEffect(() => {
+    setExecutionLogs([]);
+    setIsLogOverlayOpen(false);
+    resetRunState();
+  }, [id, resetRunState]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [testURL, setTestURL] = useState('');
const [executionLogs, setExecutionLogs] = useState<LogEntry[]>([]);
const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
const [viewShowTest, setViewShowTest] = useState<boolean>(() => {
try {
return window.localStorage.getItem(TEST_PANEL_STORAGE_KEY) === 'open';
} catch {
return false;
}
});
const showTest = mode === 'view' ? viewShowTest : true;
const handleToggleTest = () => {
if (mode !== 'view') return;
setViewShowTest((previous) => {
const next = !previous;
try {
window.localStorage.setItem(TEST_PANEL_STORAGE_KEY, next ? 'open' : 'closed');
} catch {
/* ignore */
}
return next;
});
};
const handleLog = useCallback((entry: LogEntry) => {
setExecutionLogs((prev) => [...prev, entry]);
}, []);
const { runTest, status: runStatus, result: runResult, error: runError } = useCrawlerCodeRunner({ onLog: handleLog });
const [testURL, setTestURL] = useState('');
const [executionLogs, setExecutionLogs] = useState<LogEntry[]>([]);
const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
const [viewShowTest, setViewShowTest] = useState<boolean>(() => {
try {
return window.localStorage.getItem(TEST_PANEL_STORAGE_KEY) === 'open';
} catch {
return false;
}
});
const showTest = mode === 'view' ? viewShowTest : true;
const handleToggleTest = () => {
if (mode !== 'view') return;
setViewShowTest((previous) => {
const next = !previous;
try {
window.localStorage.setItem(TEST_PANEL_STORAGE_KEY, next ? 'open' : 'closed');
} catch {
/* ignore */
}
return next;
});
};
const handleLog = useCallback((entry: LogEntry) => {
setExecutionLogs((prev) => [...prev, entry]);
}, []);
const {
runTest,
status: runStatus,
result: runResult,
error: runError,
reset: resetRunState,
} = useCrawlerCodeRunner({ onLog: handleLog });
useEffect(() => {
setExecutionLogs([]);
setIsLogOverlayOpen(false);
resetRunState();
}, [id, resetRunState]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/pages/CrawlerEditorPage.tsx` around lines 479 - 510,
When the active crawler id changes, clear per-crawler UI and runner state by
adding an effect that watches the crawler id and on change calls setTestURL(''),
setExecutionLogs([]), setIsLogOverlayOpen(false) and resets the test panel
visibility (via setViewShowTest) as appropriate, and also invoke the code runner
reset method returned from useCrawlerCodeRunner (call its reset() function) so
runTest/runStatus/runResult/runError are cleared; implement this effect near the
existing state/hooks that reference testURL, executionLogs, viewShowTest,
handleLog and useCrawlerCodeRunner.

Comment on lines +787 to +790
<GhostButton onClick={() => setIsLogOverlayOpen(true)} aria-label="Open logs">
Logs (
<span aria-live="polite" aria-atomic="true">{executionLogs.length}</span>
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

로그 카운트 live region을 버튼 밖으로 분리하세요.

aria-live가 버튼 내부에 있으면 보조기술별 알림 동작이 불안정할 수 있습니다. 버튼 이름에는 카운트를 포함하고, 별도 hidden status를 두는 편이 안전합니다.

♿ 제안 수정
-                  <GhostButton onClick={() => setIsLogOverlayOpen(true)} aria-label="Open logs">
+                  <GhostButton onClick={() => setIsLogOverlayOpen(true)} aria-label={`Open logs (${executionLogs.length})`}>
                     Logs (
-                    <span aria-live="polite" aria-atomic="true">{executionLogs.length}</span>
+                    {executionLogs.length}
                     )
                   </GhostButton>
+                  <VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
+                    {executionLogs.length} log entries
+                  </VisuallyHidden>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@applications/web/sources/pages/CrawlerEditorPage.tsx` around lines 787 - 790,
The live region for log count should be moved out of the interactive GhostButton
to avoid unreliable AT notifications; keep the button label showing the count
(e.g., "Logs (N)") but remove aria-live/aria-atomic from inside GhostButton, and
add a separate visually-hidden element (e.g., a span with aria-live="polite"
aria-atomic="true" and hidden/status-only styling) that updates with
executionLogs.length; update the GhostButton usage (onClick={() =>
setIsLogOverlayOpen(true)}) to no longer contain aria-live attributes and ensure
the new hidden live-region element is updated whenever executionLogs changes.

const onePasswordReferencePath =
process.env.OP_SERVICE_ACCOUNT_REFERENCE ??
'op://Personal/Service Account Auth Token - Audio Underview/credential';
'op://Personal/Service Account - Audio Underview/credential';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if README.md documents the exact 1Password item name and search for any references to the old item name

# Check if README mentions the specific item name
echo "=== Checking README for item name documentation ==="
rg -n "Service Account" tools/environment-generator/README.md

# Search for any other references to 1Password paths in the codebase
echo "=== Searching for other 1Password reference paths ==="
rg -n "op://Personal" --type ts --type md

# Check if there are any migration guides or changelogs mentioning this change
echo "=== Checking for migration documentation ==="
fd -e md | xargs rg -l "Service Account|1Password.*setup" 2>/dev/null

Repository: gignac-cha/audio-underview

Length of output: 465


🌐 Web query:

1Password CLI op:// reference path syntax and best practices for item naming

💡 Result:

The 1Password CLI uses op:// secret reference URIs with the syntax op://[vault]/[item]/[section]/[field], where each part can be a name or unique ID. Syntax details: - Vault: Name or ID of the vault (e.g., 'Management'). - Item: Name or ID of the item (e.g., 'PagerDuty'). - Section: Name or ID of the section (optional if no section; e.g., 'Admin'). - Field: Name or ID of the field (e.g., 'email'). - If a name contains unsupported characters (e.g., / in 'test/'), use its ID instead, obtained via op item get --format json. - Example: op://Management/PagerDuty/Admin/email refers to the 'email' field in the 'Admin' section of the 'PagerDuty' item in the 'Management' vault. Use commands like op read op://vault/item/section/field, op run, or op inject to resolve references. For item naming best practices: - No strict rules specified in official docs; choose descriptive titles for easy identification (e.g., via --title in op item create). - Use tags for organization (comma-separated with --tags; nestable with / like 'finance/insurance'). - Avoid names with special characters requiring IDs in references. - Organize with favorites, vaults, and tags rather than rigid naming. - When creating items (op item create), use descriptive titles, categories, and vaults; leverage JSON templates for complex items.

Citations:


README에 정확한 1Password 항목 이름을 문서화하세요.

현재 tools/environment-generator/README.md에는 1Password 설정이 필요하다는 것만 명시되어 있으며, 개발자가 생성해야 할 정확한 항목 이름 "Service Account - Audio Underview"가 문서화되어 있지 않습니다. 환경 설정 스크립트가 이 항목을 찾지 못할 경우 워크플로우가 실패하므로, README에 다음 정보를 추가하세요:

  • 1Password Personal 계정에서 생성할 항목 이름: Service Account - Audio Underview
  • 이 항목에 포함되어야 할 필드: credential
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/environment-generator/sources/setup.ts` at line 8, Update the README.md
to document the exact 1Password item name and required field so the environment
generator can find it: state that developers must create a 1Password Personal
item named "Service Account - Audio Underview" and include a field named
"credential" (matching the op://Personal/Service Account - Audio
Underview/credential reference in setup.ts); add a short note about where this
value is used by the setup script and an example of the item/field pair to
prevent lookup failures.

- use-before-unload: set event.returnValue alongside preventDefault for Chrome <119 compatibility
- CrawlerEditorPage: wrap content in inner component with key={id ?? 'new'} so /crawlers/abc → /crawlers/new fully resets form, edit mode, executionLogs, and runner state via remount
- use-crawler-form: markSaved returns boolean (via formRef updated in useEffect) indicating whether the in-flight submission was actually applied
- use-crawler-editor: only call modeState.exitEdit() when markSaved applied — preserves user edits made during in-flight save instead of hiding them in view mode
- CrawlerEditorPage: move Logs aria-live count out of the button into a separate VisuallyHidden role=status region; button name now includes the count via aria-label

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gignac-cha
Copy link
Copy Markdown
Owner Author

@coderabbitai review

이전 리뷰 6개 actionable comments를 commit a6eca5a에서 처리했습니다. 다시 검토 부탁드립니다.

처리한 항목

  1. use-before-unload.ts:8 (Minor)
    event.returnValue = '' 추가 (Chrome <119 + 일부 Safari 호환)

  2. use-crawler-editor.ts:34 (Critical) — isCreateMode 변경 시 상태 미리셋
    CodeRabbit 제안 (각 hook에 useEffect 추가)과 다르게, page 자체를 inner component로 감싸고 key={id ?? 'new'} 적용. id 전환 시 React가 unmount/remount하므로 useCrawlerForm/useEditorMode/useCrawlerCodeRunner/page-level useState (executionLogs, testURL 등) 전부 fresh init. setState-in-effect 패턴 회피.

  3. use-crawler-editor.ts:183 (Major) — markSaved 후 무조건 exitEdit
    markSaved가 boolean 반환 (in-flight 편집 보존 시 false). orchestrator가 applied=true일 때만 exitEdit() 호출. updater 내 side effect 회피 위해 formRef (useEffect로 갱신)로 ref 기반 비교.

  4. CrawlerEditorPage.tsx:510 (Minor) — runner state reset
    feature(web): add web application with google oauth and cloudflare pages deployment #2 (key wrapper)에 자동 흡수.

  5. CrawlerEditorPage.tsx:790 (Minor) — aria-live in button
    button 내부 <span aria-live> 제거. button aria-label에 카운트 포함 (focus 시 announce). 별도 <VisuallyHidden role=\"status\" aria-live=\"polite\"> 추가 (mount부터 DOM에 존재, 카운트 변화 announce). W3C ARIA22 패턴.

  6. setup.ts:8 (Minor, outside-diff) — README에 1Password 항목 이름 명시
    변경 안 함. 근거: setup.ts가 source of truth + op CLI 자체가 " isn't an item in the vault" 에러로 self-document. README 중복 = sync 부담. 신규 개발자는 setup 실행 시 친절한 에러 메시지로 어떤 이름이 필요한지 즉시 확인 가능. 이 PR (TES-28) scope 밖이기도 함.

검증: pnpm vitest run pages/CrawlerEditorPage 16/16 pass + pnpm lint clean.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== use-before-unload.ts ==="
fd -t f "use-before-unload" --exec cat {}

echo ""
echo "=== use-crawler-editor.ts ==="
fd -t f "use-crawler-editor" --exec cat {}

echo ""
echo "=== use-crawler-form.ts ==="
fd -t f "use-crawler-form" --exec cat {}

echo ""
echo "=== CrawlerEditorPage.tsx ==="
fd -t f "CrawlerEditorPage.tsx" --exec cat {}

Length of output: 34653


@gignac-cha 5개 수정 사항을 직접 코드를 읽으며 검증했습니다. 모두 정확하게 구현되었습니다.


✅ 수정 검증 결과

1. event.returnValue = '' (use-before-unload.ts)

올바릅니다. event.preventDefault() + event.returnValue = '' 병행은 현재 Baseline 권고 패턴입니다.

2. key={id ?? 'new'} wrapper (CrawlerEditorPage)

export function CrawlerEditorPage() {
  const { id } = useParams<{ id: string }>();
  return <CrawlerEditorPageContent key={id ?? 'new'} id={id} />;
}

각 hook의 useEffect reset보다 훨씬 깔끔한 접근입니다. executionLogs, testURL, useCrawlerCodeRunner, useCrawlerForm, useEditorMode 전부 fresh init이 보장되고, 관리 포인트가 하나입니다. fix #4(runner reset)도 정확히 흡수됩니다. ✓

3. markSaved boolean return + formRef (use-crawler-form.ts)

const formRef = useRef(form);
useEffect(() => { formRef.current = form; }); // no deps → every render

const markSaved = (submitted: FormState, next: FormState): boolean => {
  const wasApplied = formRef.current === submitted;
  setForm((current) => (current === submitted ? next : current));
  setPristine(next);
  return wasApplied;
};

race 시나리오 검증:

  • save 호출 시점: submittedForm = formState.form (특정 객체 reference 캡처)
  • await updateCrawler() 중 사용자 편집 → React 리렌더 → formRef.current = 새 객체
  • markSaved 호출: formRef.current !== submittedwasApplied = falseexitEdit() 미호출 ✓
  • setForm updater: current === submitted ? next : current → 사용자 편집 보존 ✓

wasApplied 체크(formRef)와 setForm updater의 비교 기준이 동일한 submitted reference이며, async 경계에서 모두 올바르게 동작합니다.

5. aria-live 수정 (CrawlerEditorPage.tsx)

<GhostButton
  onClick={() => setIsLogOverlayOpen(true)}
  aria-label={`Open logs (${executionLogs.length})`}
>
  Logs ({executionLogs.length})
</GhostButton>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
  {executionLogs.length} log {executionLogs.length === 1 ? 'entry' : 'entries'}
</VisuallyHidden>

W3C ARIA22 패턴과 일치합니다. role="status"aria-live="polite"가 중복되나 AT 호환성 측면에서 오히려 권장됩니다. VisuallyHidden이 조건부 마운트 없이 항상 DOM에 존재하여 카운트 변화 시 announcement가 안정적으로 동작합니다. ✓

6. setup.ts 미수정

제시하신 근거(op CLI 자체 에러 메시지로 self-document, PR 스코프 외)가 타당합니다. ✓


최종 요약

항목 상태
event.returnValue = ''
key={id ?? 'new'} unmount/remount ✅ 제안보다 우수한 접근
markSaved boolean + formRef race guard
runner state reset ✅ (key wrapper 흡수)
aria-live 버튼 외부 이동 ✅ ARIA22 준수
setup.ts README ✅ (의도적 미변경, 근거 타당)

16/16 테스트 통과 + lint clean 상태에서 모든 actionable item이 해결되었습니다. 승인합니다.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@gignac-cha gignac-cha merged commit 361283b into main Apr 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant