마지막 업데이트 2026-08-13
isSkippable)으로 눌려요. 그래서 이미 만들어진 수업의 안내판은 설계도를 나중에 고쳐도 바뀌지 않습니다 — 이게 가장 헷갈리는 지점이에요.회기 템플릿에서 활동 단위로 "아동 넘기기 허용"을 설정하면, 아동 화면에 건너뛰기 버튼이 뜨고 아동이 그 활동의 남은 스텝을 통째로 건너뛰어 다음 활동으로 이동할 수 있다. 허용되지 않은 활동은 기존과 동일하게 진행된다(기본값 OFF).
lib/activity-skip.ts, 신규) + 템플릿 설정 UI(lesson-template-form) + 저장 정규화(API 2곳 · DDB) + 회기 생성 시 스냅샷(lesson-utils) + 아동 실행 경로(use-guest-page-session · guest-layout) + 진행자 표시 배지(SkippableActivityBadge 2곳).isSkippable을 쓰는 코드는 lesson-utils.ts:108 한 곳뿐이고, 그 값은 템플릿의 skippableActivityIndices에서만 온다. 다른 경로(활동 직접 편집·소켓 이벤트·URL 파라미터)로는 켤 수 없다.값의 형태가 두 번 바뀐다. 템플릿에서는 "몇 번째 활동"(인덱스 배열), 실제 활동에서는 "이 활동이 되는지"(불리언 플래그)다.
회기 템플릿 편집 화면의 활동별 체크박스
skippableActivityIndices: number[]
lesson-template-form.tsx:776
중복·범위 밖 인덱스 제거 후 DDB 템플릿 문서에 기록
normalizeSkippableActivityIndices()
api/lesson-templates · db-queries.ts:2046
템플릿 복사 시 인덱스 → 플래그로 변환해 각 활동 문서에 굳힘
isSkippable?: true
lesson-utils.ts:105–108
아동: 버튼 표시 + skipCurrentActivity()
진행자: 배지 표시
guest-layout.tsx:334 · session-progress.tsx:241
activityTemplates는 활동 템플릿 ID 배열이고, 같은 템플릿 ID가 한 회기에 두 번 이상 들어갈 수 있다. 따라서 ID를 키로 쓰면 "두 번째로 나오는 그 활동만 허용"을 표현할 수 없어 위치(인덱스)로 지목해야 한다. 반면 생성된 활동은 개별 문서이므로 자기 자신의 불리언이 자연스럽다. jumpConfigs(키가 String(index))가 이미 쓰는 것과 동일한 패턴이다.아래 블록은 실제 소스와 동일하고, // ① 주석만 이 문서에서 덧붙인 설명이다. 소스에는 이 주석이 없다(PPI 컨벤션상 주석 최소화). 각 블록 위 파일:줄로 바로 열 수 있다.
VSCode에서 한 번에 열기 — 아래를 그대로 붙여넣으면 이 문서가 다루는 6개 지점이 순서대로 열린다.
# repo 루트에서
code -g apps/web/lib/activity-skip.ts:1
code -g apps/web/lib/lesson-utils.ts:105
code -g apps/web/entities/guest-page-session/model/use-guest-page-session.ts:2698
code -g apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx:334
code -g apps/web/components/sections/lesson-template-form.tsx:234
code -g apps/web/lib/db-queries.ts:2042
export function normalizeSkippableActivityIndices( indices: unknown, // ① unknown: API body로 들어온 값을 신뢰하지 않는다 activityCount: number, // ② "현재 활동 수" 기준으로 범위를 자른다 ): number[] { if (!Array.isArray(indices) || activityCount <= 0) return []; // ③ 미설정(undefined)·빈 템플릿 → 빈 배열 (기본값 OFF) return Array.from( new Set( // ④ 중복 제거: 체크 토글 경합으로 같은 인덱스가 두 번 들어와도 안전 indices.filter( (index): index is number => Number.isInteger(index) && index >= 0 && index < activityCount, // ⑤ 비정수·음수·활동 수 초과 인덱스를 버린다 // (활동을 줄인 뒤 남은 옛 인덱스가 여기서 정리됨) ), ), ).sort((a, b) => a - b); // ⑥ 정렬: 저장값 비교·diff를 안정화 } export function remapSkippableActivityIndicesAfterRemove( indices: number[], removedIndex: number, ): number[] { return indices .filter((index) => index !== removedIndex) // ⑦ 지워진 활동의 설정은 함께 삭제 .map((index) => (index > removedIndex ? index - 1 : index)); // ⑧ 뒤쪽 활동들은 한 칸 앞으로 밀리므로 인덱스도 -1 } export function remapSkippableActivityIndicesAfterReorder( indices: number[], newOrder: number[], // ⑨ newOrder[새 위치] = 이전 위치. 드래그 결과의 순서 배열 ): number[] { const skippableIndices = new Set(indices); return newOrder.flatMap((oldIndex, newIndex) => skippableIndices.has(oldIndex) ? [newIndex] : [], ); // ⑩ 설정이 "이전 위치"에 있었으면 그 활동의 "새 위치"로 옮긴다 } // flatMap+빈배열 = filter+map을 한 번에 (해당 없으면 결과에서 빠짐) export function isActivityIndexSkippable( indices: number[] | undefined, activityIndex: number, ): boolean { return indices?.includes(activityIndex) === true; } // ⑪ === true: undefined를 false로 확정 (옵셔널 체이닝 결과가 undefined일 수 있음)
activity-skip.test.mjs(node:test 4케이스)로 검증한다. createdAt: currentEpochTime,
updatedAt: currentEpochTime,
...(activityTemplate.isSpare && { isSpare: true }),
...(isActivityIndexSkippable(
lessonTemplate.skippableActivityIndices, // ① 템플릿의 인덱스 배열
i, // ② 현재 만들고 있는 활동의 순번
) && { isSkippable: true }), // ③ 참일 때만 필드를 넣는다(false는 저장 안 함)
...(activityJumpConfig && { jumpConfig: activityJumpConfig }),
};
lesson-form.tsx(회기 생성/수정), user-form.tsx(사용자 등록 시 일괄 생성), use-lesson-completion.ts(수업 완료 후 다음 회기 생성), batch-overwrite-modal.tsx(일괄 덮어쓰기). 즉 설정을 기존 회기에 반영하려면 이 4경로 중 하나로 활동을 재생성해야 한다.const skipCurrentActivity = useCallback(() => { if (terminatedRef.current) return false; // ① 수업 종료 후 종료 화면에서 눌려도 진행되지 않게 const currentActivity = activities[currentActivityIndex]; if (!currentActivity?.isSkippable) { // ② 실행측 게이트 — UI를 우회해 호출돼도 여기서 막힌다 logger.warn("Child activity skip ignored", { roomId, activityIndex: currentActivityIndex, reason: currentActivity ? "not-skippable" : "activity-not-found", // ③ 거부 사유를 남긴다 → 운영 로그로 원인 판별 가능 }); return false; // ④ 불리언 반환: 호출자(guest-layout)가 잠금을 되돌릴 수 있게 } logger.info("Child activity skip requested", { roomId, activityIndex: currentActivityIndex, stepIndex: currentStepIndex, // ⑤ 몇 번째 스텝에서 넘겼는지 = 얼마나 남기고 건너뜀 activityId: currentActivity.id, activityTitle: currentActivity.title, }); if (!goToNextStep(undefined, true)) { // ⑥ 2번째 인자 forceNextActivity=true // → 전환 기준 스텝을 "마지막 스텝"으로 올려 남은 스텝을 통째로 건너뛴다 // (1번째 인자 triggerText는 undefined → conditional jump 키워드 매칭 없음) logger.warn("Child activity skip ignored", { roomId, activityIndex: currentActivityIndex, reason: "no-next-target", // ⑦ 마지막 활동 등 이동 대상이 없는 경우 }); return false; } aiSessionRef.current?.cancelResponse(); // ⑧ 이동이 확정된 뒤에만 AI 발화를 끊는다 // (같은 동기 블록이라 전환 전 취소와 실효는 동일) return true; }, [activities, currentActivityIndex, currentStepIndex, goToNextStep, roomId]);
cancelResponse() 먼저, 이동 나중이었다. 그러면 마지막 활동에서 눌렀을 때 이동은 실패하면서 AI 발화만 끊기고, 호출자는 true를 받아 analytics 이벤트와 1.5초 잠금을 소비하는 먹통 버튼이 됐다. goToNextStep이 이동 성공 여부를 반환하도록 바꿔(기존 호출자 6곳은 반환값을 무시) 실패를 구분한다.forceNextActivity의 실제 효과 (goToNextStep, 같은 파일 2572–2583): transitionStepIndex = forceNextActivity ? 활동.steps.length - 1 : currentStepIndex. 즉 "지금 마지막 스텝에 있는 것처럼" 계산해 isLastStepOfActivity를 참으로 만들고, 이후 jumpConfig 기반 다음 활동 결정 로직을 그대로 탄다. 건너뛰기 전용 이동 경로를 새로 만들지 않았다는 뜻 — 예비 활동 스킵·조건부 점프 규칙이 그대로 적용된다.findNextStepSkippingSpare(lib/spare-content-utils.ts)를 그대로 타고, 이 함수는 현재 활동이 긴급종료가 아니면 후속 긴급종료 활동 구간을 통째로 건너뛴다. 따라서 아동이 건너뛰기 버튼으로 시간종료 전용 콘텐츠에 진입할 수 없다. 앞으로 "특정 활동에는 진입 금지" 규칙을 추가할 때도 이 공용 탐색 함수에 넣으면 건너뛰기 경로에 자동 반영된다.const showSkipActivityButton = session.hasConfirmedReady && // ① 입장·준비 확인 전(대기 화면)에는 숨김 session.isCurrentActivitySkippable && // ② 유일한 허용 소스 = 템플릿 설정 스냅샷 !activeSelection; // ③ "선택 후 넘기기" 화면과 버튼이 겹치는 것을 방지apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx:248–275
const handleSkipActivity = useCallback(() => { if (skipLockRef.current) return; // ① ref로 즉시 차단 — setState는 비동기라 연타를 못 막는다 skipLockRef.current = true; setIsSkippingActivity(true); // ② state는 disabled 표시용(시각 피드백) const skipped = session.skipCurrentActivity(); if (!skipped) { // ③ 훅이 거부하면 잠금 즉시 원복 (영구 비활성 방지) skipLockRef.current = false; setIsSkippingActivity(false); return; } trackEvent("ClientGuest:ActivitySkipped", { userId, roomId: props.roomId, lessonIndex: props.lessonIndex, activityIndex: session.currentActivityIndex, activityTitle: session.currentActivityTitle, // ④ 사후 분석용: 어느 활동을 아동이 실제로 건너뛰는지 }); skipUnlockTimerRef.current = setTimeout(() => { skipLockRef.current = false; setIsSkippingActivity(false); skipUnlockTimerRef.current = null; }, 1500); // ⑤ 유일한 잠금 해제 경로 — 전환 완료 여부와 무관하게 1.5초 유지 }, [...]);
a0ff593d)초기 구현에는 "활동이 실제로 바뀌면 즉시 잠금을 풀어주는" effect가 이중 안전망으로 들어 있었다. 실제로는 안전망이 아니라 연타 방지를 무력화하는 유일한 경로였다.
setCurrentActivityIndex(next.activityIndex)를 부른다(use-guest-page-session.ts goToNextStep). 즉 성공한 스킵에서는 인덱스가 반드시 바뀌므로 effect가 언제나 타이머보다 먼저 잠금을 풀었다. 1.5초 쿨다운은 사실상 죽은 코드였고, 실제로 막히는 것은 같은 tick의 동기 중복 호출뿐이었다.// 제거된 코드 (a0ff593d) // useEffect(() => { // skipLockRef.current = false; // setIsSkippingActivity(false); // clearTimeout(skipUnlockTimerRef.current); // }, [session.currentActivityIndex]); // 현재: 해제 경로는 두 갈래뿐 // ① skipCurrentActivity()가 false → 즉시 해제 (먹통 버튼 방지) // ② 성공 → 1500ms 타이머만 해제 (다음 활동도 skippable이어도 1.5초 잠김) // 언마운트 cleanup은 타이머만 정리한다
// 활동 삭제 핸들러 — jumpConfigs 재매핑과 같은 자리에 나란히 붙였다 setSelectedActivityTemplates(newTemplates); setSkippableActivityIndices((prev) => remapSkippableActivityIndicesAfterRemove(prev, index), ); // 드래그 재정렬 핸들러 — newOrder는 jumpConfigs 재매핑이 이미 만들어둔 배열을 재사용 setJumpConfigs(remapped); setSkippableActivityIndices((prev) => remapSkippableActivityIndicesAfterReorder(prev, newOrder), ); setDraggedIndex(null);
jumpConfigs 옆인가: 두 설정 모두 활동 인덱스에 매달려 있어 같은 조작에서 같이 어긋난다. 향후 인덱스 기반 설정을 추가할 때도 이 두 핸들러가 유일한 반영 지점이다.UpdateExpression: "set title = :title, activityTemplates = :activityTemplates, " + "skippableActivityIndices = :skippableActivityIndices, " // ① 여기에 없으면 체크해도 저장 안 됨 "jumpConfigs = :jumpConfigs, sessionReport = :sessionReport, updatedAt = :updatedAt", ExpressionAttributeValues: { ":title": template.title, ":activityTemplates": template.activityTemplates, ":skippableActivityIndices": template.skippableActivityIndices || [], // ② || [] — 기존 템플릿 문서에는 이 속성이 아예 없다 // DDB는 undefined를 거부하므로 방어 필수 ...
LessonTemplate에 필드를 추가할 때 반드시 함께 확인할 지점.표시(UI)와 실행(훅) 두 단계가 같은 플래그로 각각 막는다. UI만 막으면 상태 경합으로 잘못된 호출이 통과할 수 있고, 훅만 막으면 아동에게 눌리지 않는 버튼이 보인다.
| 상황 | isSkippable | hasConfirmedReady | activeSelection | 버튼 | 클릭 시 |
|---|---|---|---|---|---|
| 템플릿에서 허용한 활동, 정상 진행 중 | true | true | 없음 | 표시 | 다음 활동으로 이동 |
| 허용 안 한 활동 (기본) | undefined | true | 없음 | 숨김 | — |
| 입장 대기 / 준비 확인 전 | true | false | 없음 | 숨김 | — |
| "선택 후 넘기기" 화면 노출 중 | true | true | 있음 | 숨김 | — |
| 클릭 직후 1.5초 — 전환이 끝나 다음 활동에 있어도 유지 | true | true | 없음 | 표시 (disabled) | 무시 (skipLockRef) |
| 수업 종료 후 | true | true | 없음 | — | 거부 (terminatedRef) |
설정이 "몇 번째 활동"으로 저장되므로, 템플릿에서 활동을 지우거나 순서를 바꾸면 설정이 다른 활동으로 옮겨간다. 아래는 재매핑이 무엇을 고치는지 눈으로 본 것.
AfterRemove)변경 전 — 설정 [0, 2, 3] (인사·게임·마무리가 허용)
1번(대화) 삭제 → 재매핑 없으면 [0, 2, 3]이 그대로 남아 "게임"의 허용이 사라지고 범위 밖 3이 남는다
재매핑 적용 remapAfterRemove([0,2,3], 1) → [0,1,2] — 설정이 원래 활동을 따라간다
AfterReorder)변경 전 — 설정 [0, 2]
"게임"을 맨 앞으로 → newOrder = [2, 0, 1] (새 위치 0에 이전 2번이 옴)
재매핑 결과 remapAfterReorder([0,2], [2,0,1]) → [0,1] — 게임·인사가 계속 허용
lib/activity-skip.test.mjs, node --test):
normalize(undefined, 3) → [] · isActivityIndexSkippable(undefined, 0) → false — 기본값 OFFnormalize([2,0,2,-1,3,1.5], 3) → [0,2] — 중복·음수·초과·소수 제거 후 정렬remapAfterRemove([0,2,3], 1) → [0,1,2] / ([0,2,3], 2) → [0,2]remapAfterReorder([0,2], [2,0,1]) → [0,1]remapAfterReorder([2,4], [0,2,4]) → [1,2] — 같은 활동 템플릿이 여러 번 배치된 상태에서 한 번에 여러 순번이 제거되는 경우실행: npm run test:activity-skip (tsx --test). 처음에는 스크립트에 등록되지 않아 CI에서 돌지 않았고, .ts를 import하므로 node --test만으로는 실패한다.
인덱스 기반 저장의 위험은 "활동 목록을 편집하는 경로가 폼 하나가 아니다"라는 점이다. 폼 밖 경로는 폼의 재매핑을 타지 않는다. PR #981 리뷰에서 실제 도달 가능한 경로가 하나 발견됐고, 이후 서버 계약까지 함께 막았다.
| 층 | 파일:줄 | 막는 것 |
|---|---|---|
| ① 폼 편집 | lesson-template-form.tsx:234, 445 | 폼 안에서의 활동 삭제·드래그 재정렬 |
| ② 폼 밖 활동 제거 | components/pages/lesson-template.tsx:116–130 | 회기 템플릿 화면의 활동 표 삭제 버튼 — 이 경로가 재매핑을 빠뜨리고 있었다 |
| ③ 서버 계약 | app/api/lesson-templates/[id]/route.ts:64–89 | 활동 목록이 바뀌는 요청에 재계산된 설정이 없으면 400 SKIPPABLE_ACTIVITY_INDICES_REQUIRED |
활동 [A, B, C, D] 중 C만 허용 → skippableActivityIndices = [2]
→ 활동 표에서 B 삭제 (activityTemplates만 필터, 설정은 [2] 그대로 전송)
→ 서버 normalize([2], 3) = [2] // 범위 내라 통과, 재매핑은 하지 않음
→ 순번 2 = D → 아동이 D를 건너뛸 수 있고 C는 불가
백로그 문제 정의의 "모든 활동을 임의로 건너뛸 수 있게 하면 필수 활동까지 누락된다"를 직접 위반하는 상태였다.const survivingIndices = selectedTemplate.activityTemplates
.map((id, index) => ({ id, index }))
.filter(({ id }) => id !== activityTemplate.id)
.map(({ index }) => index);
skippableActivityIndices: remapSkippableActivityIndicesAfterReorder(
selectedTemplate.skippableActivityIndices || [],
survivingIndices
)[A,B,A] → [A,A]에서 어느 위치가 남았는지 복원 불가이고, 잘못 추측하면 설정을 오히려 옮긴다. 그래서 호출자가 재계산해 보내도록 강제한다. 기존 설정이 비어 있으면(잘못 옮겨갈 값이 없으면) 통과시켜 기존 호출자를 막지 않는다.jumpConfigs는 폼 밖 제거 경로에서 여전히 재매핑되지 않는다(이번 변경 이전부터 존재하는 결함).아동이 스스로 활동을 넘기면, 진행자 화면에서는 이유 없이 활동이 전환된 것처럼 보인다. 오작동 오해와 불필요한 재현 조사를 막기 위해 "여기는 아동이 넘길 수 있는 활동"임을 미리 알린다.
| 위치 | 파일:줄 | 표시 규칙 |
|---|---|---|
| 모니터 스텝 표 (활동/스텝 목록) | monitor-step-table.tsx:150–167 | 활동 첫 스텝 행에 제목 아래로 배지. 첫 스텝이 아니어도 아동이 현재 그 스텝에 있으면 다시 표시 — 스크롤로 활동 제목 행이 시야에서 벗어나도 정보가 사라지지 않게 |
| 세션 카드 / 1대1 포커스뷰 진행 상황 | session-progress.tsx:180, 241–245 | 현재 활동이 허용이면 현재 스텝 표시 아래 배지. isAiStep일 때 우측 UI와 겹쳐 mr-16 여백 |
| 공통 배지 컴포넌트 | shared/ui/skippable-activity-badge.tsx | "아동 넘기기 가능" + ArrowRightIcon, title에 설명 툴팁. 두 화면이 같은 표식을 쓰도록 shared/ui에 둠 |
isFirstStepOfActivity ? <>…</> : "" 삼항이 && + flex-col 컨테이너로 교체됐다. 배지를 제목 아래 줄에 세로로 놓기 위한 변경이므로, 이 셀의 레이아웃을 건드릴 때 두 배지 렌더 분기(첫 스텝 / 현재 스텝)를 함께 봐야 한다.activityTemplates.length 기준)가 범위 밖 인덱스를 잘라낸다. 반면 순서만 바꾸는 경우는 잘라낼 근거가 없어 재매핑을 거치지 않으면 설정이 엉뚱한 활동에 남는다 → 지금은 서버가 400으로 거절한다. 위 "순번 어긋남 방어 3중" 절 참조.reason: "no-next-target" WARN과 함께 실패 처리만 되고 아동 화면에서는 눌러도 아무 일도 일어나지 않는다(발화는 유지). 버튼을 숨길지, 수업 종료로 이을지는 미정.contentOverlay 조건 확장: 기존에는 isMobileLandscape일 때만 오버레이가 존재했는데 isMobileLandscape || showSkipActivityButton으로 넓어졌다. 데스크톱·세로 모드에서도 오버레이가 마운트되기 시작하므로(로고·워터마크는 내부 가드로 동일), 콘텐츠 영역 위 클릭·포인터 이벤트를 다룰 때 확인 필요. 버튼에는 pointer-events-auto가 붙어 있다.currentActivityIndex 변경 effect가 먼저 잠금을 푼다. 타임아웃이 실제로 쓰이는 경우는 전환이 실패했거나 느린 상황이므로, 이 값이 자주 소진된다면 전환 실패를 의심할 신호다.goToNextStep(undefined, true)로 기존 전환 로직을 그대로 타므로, jumpConfig(조건부 점프)·isSpare(예비 활동 스킵) 규칙이 함께 적용된다. 조건부 점프 활동에서 건너뛰면 triggerText가 undefined라 키워드 매칭 없이 기본 경로로 간다.| 로그 / 이벤트 | 출처 | 의미 |
|---|---|---|
| Child activity skip requested | 게스트 logger.info | 버튼이 눌린 시점. activityIndex·stepIndex로 "몇 번째 스텝을 남기고 넘겼는지" 확인. 뒤에 skip ignored가 없으면 전환 성공 |
| Child activity skip ignored | 게스트 logger.warn | 거부. reason: "not-skippable"은 허용 안 된 활동에서 호출된 것(UI 우회·상태 경합 의심), "activity-not-found"는 활동 배열이 비어 있는 상태, "no-next-target"은 이동할 다음 활동이 없는 경우(마지막 활동) |
| ClientGuest:ActivitySkipped | LogRocket trackEvent | 아동이 실제로 어떤 활동을 건너뛰는지 집계. 특정 활동에 집중되면 그 활동 설계 재검토 신호 |
Auto-finish transition trace (forceNextActivity: true) | 게스트 logger.info | goToNextStep 진입 추적. 건너뛰기로 인한 전환은 이 필드가 true |
Child activity skip requested가 있으면 아동이 버튼을 누른 것(정상 동작) → ② 없는데 활동이 전환됐다면 auto-finish나 진행자 조작 경로 → ③ skip ignored WARN이 반복되면 표시 게이트와 실행 게이트의 판단이 갈린 것(상태 동기화 문제). "버튼을 눌렀는데 안 넘어간다"는 신고는 requested 직후의 ignored / no-next-target 쌍으로 확정한다.| 파일/심볼 | 역할 |
|---|---|
| lib/activity-skip.ts (신규, L1–41) | 정규화 · 삭제/재정렬 재매핑 · 조회 (순수 함수 4개) |
| lib/activity-skip.test.mjs (신규) | node:test 5케이스 — 기본값·정규화·삭제·재정렬·다중 제거. npm run test:activity-skip |
| types/db/lesson-template.types.ts (L28) | skippableActivityIndices?: number[] |
| types/db/activity.types.ts (L69) | isSkippable?: boolean |
| lib/lesson-utils.ts (L105–108) | 템플릿 → 활동 스냅샷 (인덱스 → 플래그) |
| lib/db-queries.ts (L2042, L2046) | DDB UpdateExpression에 필드 추가 |
| app/api/lesson-templates/route.ts (L44) · [id]/route.ts (L60–89) | POST/PUT 서버측 정규화 (PUT은 새 활동 수 기준) + 활동 목록 변경 시 설정 동반 전송 강제 |
| components/pages/lesson-template.tsx (L116–130) | 폼 밖 활동 제거 경로의 재매핑 (남는 순번을 새 순서로 간주) |
| components/sections/lesson-template-form.tsx (L47, 68, 182, 234, 263, 445, 776) | 상태 · 로드 · 저장 · 삭제/재정렬 재매핑 · 체크박스 |
| entities/guest-page-session/.../use-guest-page-session.ts (L2573, 2701, 2976, 3024) | goToNextStep(이동 성공 여부 반환) · skipCurrentActivity · isCurrentActivitySkippable 노출 |
| widgets/guest/guest-layout/ui/guest-layout.tsx (L217, 235, 334, 339) | 잠금 해제 effect · 핸들러 · 표시 조건 · 오버레이 버튼(PC/모바일) |
| shared/ui/skippable-activity-badge.tsx (신규) | 진행자 공통 "아동 넘기기 가능" 배지 |
| components/sections/monitor-step-table.tsx (L150–167) · features/session/ui/session-progress.tsx (L180, 241) | 배지 표시 2곳 |
| public/skip-forward-{desktop,mobile}.svg (신규) | 버튼 아이콘 (모바일은 원형 배경 포함, rotate-90 적용) |
goToNextStep·jumpConfig·인덱스 기반 이동의 기반 문서. 건너뛰기는 이 전환 로직을 재사용한다cancelResponse()로 끊는 AI 발화 턴의 입력 게이트findNextStepSkippingSpare()를 공유하며, 긴급종료 묶음은 건너뛰기 이동 대상에서 자동 제외된다