TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證탭 간 동시편집 가능한 스프레드 시트
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
같은 브라우저의 여러 탭에서 동시에 편집하고, AI에게 편집을 요청할 수 있는 웹 스프레드시트입니다. 요구 사항: Node.js 20.9 이상 (.nvmrc 참고), pnpm AI 편집은 OpenRouter를 통해 실제 LLM에 연결합니다. 키는 서버(Route Handler)에서만 읽으므로 브라우저에 노출되지 않습니다. 1. OpenRouter에서 API 키를 만들고 선불 크레딧을 소액($5~10) 충전합니다. 충전액이 지출 상한이 되고, 초과하면 요청이 402로 거절되며 패널에 "사용 한도 초과"로 안내됩니다 (A7/D11). 2. .env.example를 .env.local로 복사하고 키를 채웁니다. 구현한 것: 시트 편집(선택·입력·키보드 이동·IME), 행·열 헤더 클릭·드래그로 행·열 전체 선택(S1 P2), 셀 서식(굵게·기울임·밑줄·취소선·정렬·글자 크기), 실행 취소/다시 실행, 영속화(IndexedDB), 탭 간 공동 편집(실시간 반영·참여자 표시·탭 종료 정리·내 변경만 undo), 여러 시트와 목록, AI 편집(실제 LLM 스트리밍·범위 지정·검토 후 적용·취소·실패 처리, 멀티탭 충돌·작업 표시·겹침 경고).檢視完整 README ↗
같은 브라우저의 여러 탭에서 동시에 편집하고, AI에게 편집을 요청할 수 있는 웹 스프레드시트입니다.
요구 사항: Node.js 20.9 이상 (.nvmrc 참고), pnpm
pnpm install
pnpm dev
AI 편집은 OpenRouter를 통해 실제 LLM에 연결합니다. 키는 서버(Route Handler)에서만 읽으므로 브라우저에 노출되지 않습니다.
.env.example를 .env.local로 복사하고 키를 채웁니다.cp .env.example .env.local
# OPENROUTER_API_KEY=sk-or-... 를 채운다
| 환경변수 | 필수 | 설명 |
|---|---|---|
OPENROUTER_API_KEY | 예 | OpenRouter API 키 |
OPENROUTER_MODEL | 아니오 | 모델 override. 기본값은 openai/gpt-5-mini |
| 스크립트 | 설명 |
|---|---|
pnpm dev | 개발 서버 |
pnpm build / pnpm start | 프로덕션 빌드 / 실행 |
pnpm lint | ESLint (feature 경계 규칙 포함) |
pnpm typecheck | TypeScript 타입 검사 |
pnpm test | Vitest 단위 테스트 |
pnpm format | Prettier 포맷 |
구현한 것: 시트 편집(선택·입력·키보드 이동·IME), 행·열 헤더 클릭·드래그로 행·열 전체 선택(S1 P2), 셀 서식(굵게·기울임·밑줄·취소선·정렬·글자 크기), 실행 취소/다시 실행, 영속화(IndexedDB), 탭 간 공동 편집(실시간 반영·참여자 표시·탭 종료 정리·내 변경만 undo), 여러 시트와 목록, AI 편집(실제 LLM 스트리밍·범위 지정·검토 후 적용·취소·실패 처리, 멀티탭 충돌·작업 표시·겹침 경고).
의도적으로 제외한 것:
sheet-{id} IndexedDB 정리, 삭제·재생성 경합 등 새 동시성 표면을 만든다. P0·P1 대비 복잡도가 커서 제외했다.=SUM(...)은 문자열로 저장만 하고 계산하지 않는다.탭마다 문서 복제본이 있고 각자 따로 편집되므로, 변경이 도착하는 순서가 탭마다 달라도 모든 탭이 같은 결과로 수렴해야 합니다. 받은 변경을 단순히 덮어쓰면 두 탭이 거의 동시에 같은 셀을 고쳤을 때 탭마다 다른 값이 남습니다.
Yjs는 변경마다 (clientID, clock) ID를 붙이고 모든 복제본이 같은 규칙으로 병합하는 CRDT입니다. 중앙에서 순서를 정해 주는 존재가 없어도 수렴하므로, 탭끼리 변경분만 주고받으면 됩니다.
직접 구현하는 대신 Yjs를 고른 결정적인 이유는 과제 요구사항 두 가지를 기본으로 지원하기 때문입니다.
Y.UndoManager에 trackedOrigins를 지정하면, 로컬에서 만든 변경만 undo 대상이 되고 다른 참여자의 변경은 건드리지 않습니다. 내가 바꾼 셀을 다른 사람이 다시 바꾼 경우의 처리도 라이브러리가 맡습니다. 동시편집 상황에서 직접 구현하면 버그가 가장 많이 나는 부분입니다.transact로 묶고 앞뒤로 stopCapturing()을 호출하면 한 단위로 기록됩니다.이 밖에 y-indexeddb(영속화)와 y-protocols/awareness(참여자 표시)가 같은 생태계에 있어 연결 비용이 작습니다.
셀은 평탄화된 단일 맵에 cells: Y.Map<"r:c:속성", 값> 형태로 저장합니다 (값은 "3:2:v", 서식은 "3:2:bold"). 값과 서식이 처음부터 서로 다른 최상위 키라서, 한 사람이 값을 바꾸는 동안 다른 사람이 굵게 처리해도 두 변경이 모두 남습니다. 같은 키를 동시에 바꾸면 한쪽이 결정적으로 선택됩니다(LWW). 이 구조를 고른 이유는 아래 "셀 저장 구조" 참고.
"모든 탭을 닫았다 열어도 유지"와 "새 탭에서 바로 보임"을 만족하려면, origin 안에서 탭 간에 공유되고 영구적으로 남는 저장소가 필요합니다.
| 저장소 | 판단 |
|---|---|
| IndexedDB | 탭 간 공유, 영구 보관, 비동기, 바이너리(Uint8Array) 저장, 트랜잭션 지원. y-indexeddb가 있음 → 선택 |
| localStorage | 동기 API라 입력할 때마다 메인 스레드를 막음. 문자열만 저장돼 Yjs update를 base64로 변환해야 함(약 33% 증가). 용량 약 5MB. 키 하나를 통째로 덮어쓰는 구조라 두 탭이 동시에 쓰면 한쪽이 사라짐 |
| sessionStorage | 탭마다 분리되고 탭을 닫으면 삭제됨. 요구사항과 맞지 않음 |
| Cache Storage | HTTP 요청/응답 캐시용. 데이터를 가짜 URL의 Response로 감싸야 하고, 트랜잭션과 순서 보장이 없어 압축 도중 중단되면 상태가 어긋날 수 있음 |
| OPFS | 파일 단위 API. 빠른 동기 쓰기는 전용 워커에서만 가능하고 파일에 배타적 잠금이 걸림. 비동기 쓰기는 닫을 때 파일을 통째로 교체해 여러 탭의 동시 쓰기를 직접 설계해야 함. Yjs 연동 라이브러리 없음 |
Yjs 저장은 "전체 상태 덮어쓰기"가 아니라 "변경분을 추가로 쌓고 가끔 압축하기"입니다. IndexedDB는 레코드 단위 추가와 트랜잭션을 지원해서 이 방식에 잘 맞고, 여러 탭이 같은 변경분을 중복으로 저장해도 Yjs update는 여러 번 적용해도 결과가 같아 문제가 없습니다.
IndexedDB에는 다른 탭의 변경을 알려주는 이벤트가 없으므로, 실시간 전파는 아래의 BroadcastChannel이 맡습니다.
같은 origin의 탭끼리 메시지를 주고받는 브라우저 API입니다. Yjs update와 awareness 메시지를 바이너리 그대로 실어 보냅니다.
storage 이벤트는 localStorage가 바뀔 때만 발생해서 문자열 변환과 동기 쓰기 비용이 따르고, window.postMessage는 opener 관계의 창끼리만 쓸 수 있어 실질적인 대안은 SharedWorker였습니다.
SharedWorker 중앙 서버식과의 비교
SharedWorker로는 CRDT 없이도 구현할 수 있습니다. 워커가 모든 탭의 편집을 받은 순서대로 적용하고 같은 순서로 다시 뿌리면 결과가 항상 일치합니다. 같은 기기 안이라 왕복이 짧아서, 응답을 받은 뒤 반영해도 체감 지연이 거의 없습니다.
| Yjs + BroadcastChannel | SharedWorker 중앙 서버식 | |
|---|---|---|
| 결과 일치 | Yjs가 보장 | 워커가 순서를 정해 보장 |
| 새 탭 초기 상태 | IndexedDB 로드 + 채널로 누락분 요청 | 워커가 현재 상태를 바로 전달 |
| 내 변경만 undo | UndoManager 설정 | 참여자별 undo 스택, 역연산, 다른 사람이 덮어쓴 경우의 처리를 직접 구현 |
| 장애 영향 | 탭 하나가 죽어도 나머지는 정상 | 워커가 죽으면 전체 동기화가 멈춤 |
| 코드 버전 | 새로고침한 탭은 새 코드로 동작 | 탭이 하나라도 열려 있으면 옛 워커 코드가 계속 남음 (배포·HMR 시) |
| 브라우저 지원 | 모든 주요 브라우저 | Chrome Android 미지원 |
SharedWorker의 장점인 "원본이 한 곳"과 "쓰는 곳이 한 곳"은 CRDT를 쓰면 필요 없어집니다. CRDT는 중앙 없이 동등한 복제본이 수렴하도록 만든 알고리즘이고, 중복 저장도 무해하기 때문입니다. 남는 것은 워커 코드와 단일 장애점이라는 비용뿐이라 BroadcastChannel을 선택했습니다. 특히 명세의 "내 변경만 undo"를 직접 구현하는 위험을 피하는 것이 컸습니다.
Yjs에는 BroadcastChannel 전용 provider가 없어 직접 작성합니다(features/collab/broadcast-provider.ts). y-webrtc도 내부에서 BroadcastChannel을 쓰지만, WebRTC 의존성이 딸려 오고 "브라우저 API만으로"라는 조건에서 동작을 명확히 설명하기 어려워 사용하지 않았습니다.
참여자 이름·색·선택 위치는 저장되지 않는 휘발 상태라 Y.Doc이 아닌 별도 채널이 필요합니다.
y-protocols/awareness. clock 기반 상태 병합, 제거 프로토콜, 타임아웃 정리가 이미 구현돼 있고 yjs와 같은 생태계라 추가 전송 의존성이 없다.unknown으로 불투명하게 다룬다 — 코어는 셀 개념을 모른다는 경계 유지. 구체 타입은 sheet 공개 API를 쓸 수 있는 collab/ui에 있다.비정상 종료(크래시)된 참여자를 수 초 안에 지우되, 절전된 백그라운드 탭을 잘못 지우면 안 됩니다(C6).
Y.applyUpdate(doc, update, provider)로 적용하고, doc.on("update")에서 origin이 자신이면 재전송하지 않는다. Yjs 표준 관례로, collab이 sheet의 심볼(LOCAL_ORIGIN)을 의존하지 않아도 에코 차단과 undo 제외(trackedOrigins)가 모두 성립한다.REMOTE_ORIGIN 상수 — 의도는 같지만 collab에 별도 심볼이 생기고 provider가 여럿일 때 구분이 안 된다."r:c:속성" 키)셀을 어떻게 저장할지 두 가지를 검토했습니다: 셀마다 중첩 Y.Map을 두는 구조(cells["3:2"] = Y.Map{v, bold})와, 속성까지 키에 넣어 한 겹으로 펴는 구조(cells["3:2:v"], cells["3:2:bold"]).
parseCellKey)을 감수하고 특수 처리 두 개를 없애는 쪽을 골랐다.:v, :bold)에 의존한다. 이 규약은 store 안에만 있고 밖으로는 타입 있는 API(getCellValue 등)로만 노출된다.서식 4종(굵게·기울임·밑줄·취소선, D3)은 평탄 키("r:c:bold")에 true로 저장하고, 끄면 키를 지웁니다.
false 잔여 키가 남지 않는다. 대안(문자열 "1")은 맵 타입은 좁게 유지하지만 파싱 규약이 하나 더 생긴다.시트마다 Y.Doc과 IndexedDB(sheet-{id})가 따로라서 시트를 전환하면 새 doc을 로드하는 짧은 공백이 생긴다. S6은 "빈 시트가 깜빡이지 않게 로딩 상태를 보여 준다"고 하는데, 화면 전체를 안내 문구로 바꾸면 전환할 때마다 툴바·헤더가 사라졌다 나타나 더 튀어 보였다.
SheetToolbarSkeleton, SheetGridSkeleton), 셀 영역만 비워 pulse로 표시한다(aria-busy). 툴바 JSX는 ToolbarView로 분리해 실제 툴바와 스켈레톤이 같은 마크업을 쓴다 — 로드가 끝나도 레이아웃이 움직이지 않는다.metrics.ts)에 의존하므로 셀 크기를 바꾸면 둘 다 따라가야 한다. 대신 상수를 공유해 어긋날 여지는 없다.툴바(서식 버튼 눌린 상태, 적용 대상 범위)는 Grid 내부의 선택 상태가 필요하지만 Grid의 형제 컴포넌트입니다.
onPresenceChange(cursor) 콜백을 재사용해 SheetScreen이 커서를 React state로 미러링, Toolbar에 내린다. 원본은 여전히 Grid의 useSelection 한 곳(Q2)이고 미러는 단방향 사본이다.useSelection을 SheetScreen으로 리프트 — 선택 로직(키보드·드래그)이 Grid에 묶여 있어 feature 내부 훅을 공개해야 하고 API 표면이 커진다. Grid에 툴바 슬롯 — 툴바 레이아웃(참여자 목록과 나란히)이 SheetScreen 소유라 어긋난다.한글 자판이 활성이면 e.key가 'b' 대신 'ㅠ'로 보고되어 ⌘B·⌘Z가 동작하지 않습니다. 한국어 입력이 핵심 시나리오(S2)인 앱이라 레이아웃 독립적인 e.code(KeyB, KeyZ 등)로 매칭합니다. 서식 단축키는 IME 조합 가드보다 앞에 두어 조합 중에도 동작합니다 — ⌘/Ctrl 조합은 조합 입력이 아니고 셀 텍스트를 건드리지 않기 때문입니다.
AI 편집은 A1A7·X1X3으로 커서 3개 하위 묶음으로 나눠 진행했습니다: ①제안 생성·검토·적용(단일 탭, mock) → ②스트리밍·취소·실패(OpenRouter Route Handler) → ③멀티탭 충돌·표시(X1~X3). 가장 큰 위험(제안 상태 모델·배치 적용·undo·스키마)을 LLM 없이 먼저 고정하기 위한 순서였습니다. 셋 다 구현했습니다("공동 편집과 AI가 만나는 지점" 참고).
ProposalSource): 묶음 1은 mock, 묶음 2에서 실제 스트리밍 클라이언트(createOpenRouterSource)로 교체했다. store·UI 시그니처를 거의 바꾸지 않고(요청 범위 라벨 targetLabel만 추가) LLM 없이 검토·적용 경로를 먼저 완결 검증할 수 있었다(provider 경계 예외).fetch + Route Handler(/api/ai/propose): AI SDK 등 새 의존성 대신 OpenRouter(OpenAI 호환)를 직접 호출한다. 키 취급(서버 env만), 실패 분류(402/429/5xx/네트워크/타임아웃), 클라이언트 취소의 업스트림 전파(request.signal→업스트림 fetch, A4)를 전부 직접 통제하기 위해서다. 실제 네트워크 동기화로 확장할 때 바꿀 경계가 이 라우트 하나로 모인다(R4). require_parameters:true·data_collection:"deny"로 구조화 출력 지원 업체로만 라우팅한다(D11).{"changes":[...]} 하나를 토큰 단위로 스트림하므로, 라우트에서 배열 안 객체가 닫힐 때마다(createChangesExtractor, 순수) 떼어 NDJSON item 라인으로 보낸다. 패널이 "생성되는 대로 하나씩" 쌓이고(A4), 클라이언트는 라인만 읽어 parseProposalItem으로 최종 검증한다(검증 로직 단일화).cancelled(사용자 취소)·error(실패, AiErrorKind 포함) 상태를 두되 둘 다 적용 불가(canApply는 complete만). 중단·실패해도 그때까지 받은 부분 제안은 패널에 남아 확인만 가능하고(A6/D10), 같은 입력으로 재시도한다(retry).doc.transact를 타지 않으므로 다른 탭에 새지 않는다(X2 근거). 요청 시점의 base를 고정해 두면 묶음 3의 충돌 비교(base≠현재, X1) 재료가 확보된다.applyCellValues로 한 transact(A5): UndoManager가 undo 1단계로 캡처한다. 적용 앞뒤로 stopCapturing()을 호출해 직전·직후 입력과 합쳐지지 않게 한다. stopCapturing은 store가 아니라 proposal-store가 호출한다 — "store는 전송·저장(undo 포함)을 모른다"는 경계를 유지하기 위해 store는 순수 transact만 노출한다.openai/gpt-5-mini처음엔 gpt-4o-mini(버전 고정)로 시작했고, 3단계에서 비용·속도·정확도를 놓고 다시 골랐다. 이 과제의 요청은 시트 표 전체가 입력으로 들어가고 출력은 바뀐 셀 몇 개뿐이라, 비용은 출력이 아니라 입력 단가가 좌우한다. 그리고 오답의 대부분이 "B열 아래로 3칸"류의 공간 추론이라 모델 급이 정확도에 직접 영향을 준다.
| 후보 (OpenRouter) | 입력 $/1M | 출력 $/1M | 추론 | 비고 |
|---|---|---|---|---|
openai/gpt-4o-mini | 0.15 | 0.60 | × | 이전 기본값. 공간 지시에서 off-by-one 잦음 |
openai/gpt-4.1-mini | 0.40 | 1.60 | × | 빠르고 구조화 출력 안정. 추론 없음 |
openai/gpt-5-mini | 0.25 | 2.00 | ○ | 입력 단가 낮고 공간 추론 유리. 첫 토큰 약간 늦음 |
openai/gpt-5.4-mini | 0.75 | 4.50 | ○ | 가장 강하지만 3배 비용 — 과제 규모에 과함 |
gpt-5-mini. 입력 단가가 4.1-mini보다 낮고, 출력은 양이 작아 단가 차이가 비용에 거의 반영되지 않는다. 추론 모델이라 좌표 변환·범위 판단이 안정적이다.OPENROUTER_MODEL=openai/gpt-4.1-mini로 바꾸면 된다(코드 변경 없음). D11의 "버전 고정"은 gpt-5-mini에 날짜 변형 id가 없어 기본 id를 쓴다.row·col처음엔 값 있는 셀을 A1="작업", C2="담당"처럼 sparse로 나열하고, 출력은 {row, col, value} 정수 인덱스로 받았다. 입력은 A1 표기, 출력은 0-based 인덱스라 체계가 둘이었고, 모델이 매 요청마다 라벨을 좌표로 역산해야 했다. 관찰된 오답 유형은 세 가지였다: 행 번호 off-by-one(행3 → row 3), 열 문자 오역(C → col 3), 범위 지정 요청에서 범위 밖 셀 제안(D8 파서가 버려 결과가 비어 보임).
행3(row 2) | "x" | · | "y", 열 머리는 B(col 1). 빈 셀도 ·로 자리를 차지해 열이 정렬돼 보인다. 선택·대상 범위도 B2(row 1, col 1):D10(row 9, col 3)으로 병기하고, "표에 적힌 숫자를 그대로 쓰고 계산하지 마라"고 지시한다. 출력 스키마는 그대로 둔다. 프롬프트 조립은 features/ai/model/prompt.ts 순수 함수(renderSheetTable, describeLabel, buildSystemPrompt)라 단위 테스트한다.{cell: "B3"})로 받아 체계를 하나로 — 가장 깔끔하지만 스키마·파서·스트림 추출과 그 테스트를 전부 바꿔야 해 X1 작업과 충돌했다. 입력만 바꿔도 변환을 모델이 "읽기"로 끝낼 수 있어 이쪽을 먼저 택했다. 다음 묶음에서 통일 검토.여러 시트를 만들고 오갈 수 있어야 합니다(/=목록, /sheet/[id]=편집).
sheet-list: 시트 목록은 셀 도메인이 아니라 features/sheet에 넣지 않고, collab은 시트 개념을 금지하며, composition은 조립 전용이다. 목록 데이터·ops는 새 feature가 소유한다.sheet-registry): 시트 목록은 셀과 독립적으로 동기화·영속되어야 하므로 전용 문서를 둔다. 셀과 같은 평탄 키("{id}:name", "{id}:createdAt")로 저장해 동시 생성 시 컨테이너 LWW 소실을 막는다. 참여자 개념이 없어 provider의 awareness를 optional로 두고 목록 doc에는 넘기지 않는다./와 /sheet/[id]가 같은 registry doc 인스턴스를 쓴다. 각 화면이 별도 세션을 만들면, 생성 직후 이동한 편집 화면이 아직 IndexedDB에 flush되지 않은 시트를 못 찾고(재브로드캐스트할 이전 provider도 이미 해제됨) "찾을 수 없음"에 갇힐 수 있다. 공유하면 방금 만든 시트를 게이트가 즉시 본다.sheet-{id} IndexedDB를 만들지 않게 한다. 목록 로드 전에는 판정할 수 없어 로딩을 표시하고, 다른 탭이 방금 만든 시트라면 동기화가 도착하는 대로 자동으로 편집 화면으로 전환된다.공동 편집(2단계) 전에는 React 상태로도 충분하지만, 그렇게 시작하면 2단계에서 저장 구조·편집 연산·구독을 전부 다시 쓰고 그 사이 "상태는 한 곳에만" 규칙을 위반한 상태를 거칩니다.
cells: Y.Map에 저장하고, UI는 store의 subscribe/getCellValue(useSyncExternalStore)로만 읽는다. 로컬 편집은 전부 LOCAL_ORIGIN transact로 실행한다.Map 상태로 시작 — 1단계 코드는 약간 단순하지만 2단계에서 전면 재작성.한글(IME)로 "바로 입력"을 시작할 때 첫 글자가 유실되지 않아야 합니다(S2 P0). 편집 진입 시 input을 마운트하고 포커스를 옮기는 방식은 compositionstart가 포커스 이동 전에 발생해 첫 글자가 빠지거나 분리됩니다.
isComposing으로 한 곳(useGridKeyboard)에서 무시한다.S2(Enter=이어서 편집)와 S3 표(Enter=아래로 이동)가 충돌해 하나를 골라야 했습니다.
docs/spec.md S3 표를 함께 수정했다.같이 바뀌는 코드끼리 모으기 위해 기술 종류(components, hooks 등)가 아니라 기능 단위로 나눴습니다.
src/
app/ # 라우트만
features/
sheet/ # 셀 주소·범위(model), Y.Doc 구조·편집 연산·undo(store), 그리드 UI(ui)
collab/ # IndexedDB 영속화, BroadcastChannel 동기화, presence
ai/ # (3단계) AI 편집
composition/ # 기능 조립: Y.Doc·저장·동기화·undo 생성과 해제
shared/ # 기능과 무관한 공통 UI·유틸
ai ──→ sheet ←── collab/ui
↑
composition (조립)
sheet는 collab·ai를 모릅니다. 탭이 하나든 여러 개든 똑같이 동작하는 편집 로직만 담습니다.collab 코어는 Y.Doc만 다룹니다. 셀이라는 개념을 몰라서, 네트워크 동기화로 확장할 때 이 폴더의 provider만 교체하면 됩니다. 그리드 위에 다른 참여자의 선택 범위를 그리는 collab/ui만 sheet의 공개 API를 사용합니다.composition에서만 합니다.index.ts로만 공개합니다. 내부 파일 직접 import와 위 의존 방향 위반은 ESLint no-restricted-imports로 막습니다.Grid는 좌표 계산과 오버레이 자리만 제공하고, 다른 참여자의 선택 표시나 AI 제안 하이라이트는 각 feature가 자기 레이어로 전달합니다. Grid가 이들을 직접 import하면 sheet가 collab·ai에 의존하게 되기 때문입니다.model/)과 외부에 닿는 코드(Yjs, IndexedDB, React)를 나눠 순수 로직을 단위 테스트합니다.AI 제안은 적용 전까지 요청자 개인의 초안이고 Y.Doc에 넣지 않습니다. 동기화되는 것은 "누가 어느 범위에서 작업 중"이라는 awareness 표시뿐이며, 제안 값 자체는 다른 탭에 새지 않습니다. 셋 다 presence 기반 잠금을 쓰지 않습니다 — 요청자 탭이 죽으면 풀리지 않는 잠금이 남기 때문입니다. 대신 적용 시점의 값 검증 하나로 모든 경합을 일관되게 처리합니다.
생성은 멈추지 않습니다. 요청하는 순간 대상 셀들의 원래 값(base)을 고정해 두고, 적용할 때 셀마다 현재 값과 base를 비교합니다. 달라졌으면 충돌로 표시하고 원래값·현재값·AI값을 나란히 보여 사용자가 고르게 합니다. 기본값은 "덮어쓰지 않음"(현재 값 유지)이라, 사람이 직접 한 편집이 AI 결과에 조용히 덮이지 않습니다. 검토 중 다른 탭이 그 셀을 또 바꾸면 SheetStore 구독으로 충돌 표시가 실시간 갱신됩니다. 충돌 셀은 패널과 그리드 오버레이 모두 경고색으로 구분합니다.
요청자 색의 점선 테두리와 "○○ · AI 생성 중 / 검토 중" 배지가 대상 범위에 보입니다(awareness의 aiTask 필드). 제안 값은 보이지 않고 셀 편집도 막지 않습니다 — 확정 안 된 개인 초안이기 때문입니다. 요청자가 적용·버리기·탭 닫기를 하면 표시가 사라집니다(탭 종료는 awareness 제거로 자동). 범위 없는 시트 전체 요청이면 테두리 대신 좌상단 배지만 띄웁니다.
요청 범위가 다른 참여자의 aiTask 범위와 겹치면(rangeOverlaps) "○○님이 이 범위에서 AI 작업 중" 경고를 띄우되 진행은 허용합니다. presence 잠금으로 막으면 두 탭이 동시에 요청할 때 둘 다 통과할 수 있어 일관성이 깨집니다. 먼저 적용한 쪽이 반영되고, 나중에 적용하는 쪽은 그새 값이 바뀌었으므로 위 X1의 충돌 검사에 자동으로 걸립니다 — 한 가지 메커니즘으로 모든 순서를 처리합니다.