From 5f7ca99f44ea20e1a3fd4570d9d99130e76eb06c Mon Sep 17 00:00:00 2001 From: corpi Date: Thu, 5 Mar 2026 18:18:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(resume):=20=EC=A7=80=EB=82=9C=20=ED=95=9C?= =?UTF-8?q?=20=EC=A1=B0=EA=B0=81=20=EC=9D=B4=EC=96=B4=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=ED=95=98=EB=8A=94=20=EC=A7=84=EC=9E=85=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥락: - /space 재진입 시 마지막 목표를 다시 쓰게 되어 시작 마찰이 컸다. - work.md 작업 2 요구사항에 맞춰 목표 기반 Resume CTA를 진입 의식 안에 추가했다. 변경사항: - workspace localStorage 스키마에 goal 필드를 추가하고 저장/복원에 반영했다. - Setup Ritual에 지난 한 조각 이어서 블록을 추가했다. - 이어서 시작은 저장 목표로 즉시 Focus 전환, 새로 시작은 목표 초기화 후 새 세션 입력으로 전환하도록 연결했다. - session 문서 docs/session_brief.md, docs/90_current_state.md를 최신 상태로 갱신했다. 검증: - npx tsc --noEmit 세션-상태: Resume CTA와 목표 복원 흐름이 /space 진입에 반영됨 세션-다음: Goal Complete 루프와 Recover(Notes→Inbox) 플로우 마감 세션-리스크: localStorage 기반 복원이라 다중 탭/스토리지 초기화 시 세션 연속성이 약할 수 있음 --- docs/90_current_state.md | 13 +++-- docs/session_brief.md | 11 +++-- .../ui/SpaceSetupDrawerWidget.tsx | 29 +++++++++++ .../ui/SpaceWorkspaceWidget.tsx | 49 +++++++++++++++++-- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 2dc0a1a..2593ce0 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -48,8 +48,12 @@ Last Updated: 2026-03-05 - 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리: - `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정 - 세션 상태 더미 저장/복원 추가: - - `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)`를 localStorage에 저장 + - `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장 - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 +- `/space` 진입 Resume CTA 추가: + - 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출 + - `이어서 시작`: 저장 목표로 즉시 Focus 진입 + - `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환 - 세션 복구 운영 문서 추가: - `docs/06_commit_convention.md` - `docs/07_session_recovery.md` @@ -149,10 +153,9 @@ Last Updated: 2026-03-05 ## NEXT -1. Packs/Profiles 상세 패널(더미) 설계 여부 결정 및 UX 깊이 조정 -2. Plan Pill(NORMAL) 클릭 시 업그레이드 진입 기대치에 대한 카피/마이크로 인터랙션 점검 -3. Scene 추천 자동 적용과 override 유지 정책의 체감 검증 -4. ESLint 잔여 이슈(`set-state-in-effect` 등) 정리 계획 수립 +1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감 +2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리 +3. Stage 가독성/모션/레이어 폴리시 최종 통일 ## RISKS diff --git a/docs/session_brief.md b/docs/session_brief.md index c25f3bf..ac38dde 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -14,9 +14,9 @@ Last Updated: 2026-03-05 ## 현재 우선순위 -1. Packs/Profiles 더미 UI의 정보 밀도와 업그레이드 동선 카피 미세 조정 -2. Scene 추천 매핑 품질 점검(공간별 사운드/타이머 추천값 보정) -3. ESLint 잔여 이슈(`set-state-in-effect` 등) 정리 범위 확정 +1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검 +2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리 +3. Stage 가독성/모션/레이어 폴리시 최종 정리 ## 최근 세션 상태 @@ -50,8 +50,11 @@ Last Updated: 2026-03-05 - 추천 정보 1줄 + `추천으로 되돌리기`만 유지 - 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다. - `/space` 선택 상태 로컬 저장/복원을 추가했다. - - 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)` + - 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)` - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 +- `/space` 진입 시 Resume CTA를 추가했다. + - 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출 + - `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환 - 세션 복구용 문서/템플릿/스크립트가 준비되어 있다. - `workFlow.md`는 토큰 절약 모드를 사용한다. - `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다. diff --git a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx index 300719d..c18ae4d 100644 --- a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx +++ b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx @@ -28,6 +28,11 @@ interface SpaceSetupDrawerWidgetProps { onGoalChange: (value: string) => void; onGoalChipSelect: (chip: GoalChip) => void; onStart: () => void; + resumeHint?: { + goal: string; + onResume: () => void; + onStartFresh: () => void; + }; } interface SummaryChipProps { @@ -74,6 +79,7 @@ export const SpaceSetupDrawerWidget = ({ onGoalChange, onGoalChipSelect, onStart, + resumeHint, }: SpaceSetupDrawerWidgetProps) => { const [openPopover, setOpenPopover] = useState(null); const panelRef = useRef(null); @@ -151,6 +157,29 @@ export const SpaceSetupDrawerWidget = ({

목표만 적으면 바로 Focus 모드로 넘어가요.

+ {resumeHint ? ( +
+

지난 한 조각 이어서

+

{resumeHint.goal}

+
+ + +
+
+ ) : null} +
; } @@ -143,6 +144,13 @@ const resolveInitialTimerLabel = ( export const SpaceWorkspaceWidget = () => { const searchParams = useSearchParams(); const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []); + const roomQuery = searchParams.get('room'); + const goalQuery = searchParams.get('goal'); + const soundQuery = searchParams.get('sound'); + const timerQuery = searchParams.get('timer'); + const storedGoal = storedSelection.goal?.trim() ?? ''; + const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery); + const canOfferResume = storedGoal.length > 0 && !hasQueryOverrides; const { thoughts, thoughtCount, @@ -154,16 +162,16 @@ export const SpaceWorkspaceWidget = () => { setThoughtCompleted, } = useThoughtInbox(); - const initialRoomId = resolveInitialRoomId(searchParams.get('room'), storedSelection.sceneId); + const initialRoomId = resolveInitialRoomId(roomQuery, storedSelection.sceneId); const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0]; - const initialGoal = searchParams.get('goal')?.trim() ?? ''; + const initialGoal = goalQuery?.trim() ?? ''; const initialSoundPresetId = resolveInitialSoundPreset( - searchParams.get('sound'), + soundQuery, storedSelection.soundPresetId, initialRoom.recommendedSoundPresetId, ); const initialTimerLabel = resolveInitialTimerLabel( - searchParams.get('timer'), + timerQuery, storedSelection.timerPresetId, initialRoom.recommendedTimerPresetId, ); @@ -173,6 +181,7 @@ export const SpaceWorkspaceWidget = () => { const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); const [goalInput, setGoalInput] = useState(initialGoal); const [selectedGoalId, setSelectedGoalId] = useState(null); + const [showResumePrompt, setShowResumePrompt] = useState(canOfferResume); const [selectionOverride, setSelectionOverride] = useState({ sound: Boolean(storedSelection.override?.sound), timer: Boolean(storedSelection.override?.timer), @@ -266,11 +275,16 @@ export const SpaceWorkspaceWidget = () => { }; const handleGoalChipSelect = (chip: GoalChip) => { + setShowResumePrompt(false); setSelectedGoalId(chip.id); setGoalInput(chip.label); }; const handleGoalChange = (value: string) => { + if (showResumePrompt) { + setShowResumePrompt(false); + } + setGoalInput(value); if (value.trim().length === 0) { @@ -283,6 +297,7 @@ export const SpaceWorkspaceWidget = () => { return; } + setShowResumePrompt(false); setWorkspaceMode('focus'); }; @@ -309,6 +324,11 @@ export const SpaceWorkspaceWidget = () => { } const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); + const normalizedGoal = goalInput.trim().length > 0 + ? goalInput.trim() + : showResumePrompt + ? storedGoal + : ''; window.localStorage.setItem( WORKSPACE_SELECTION_STORAGE_KEY, @@ -316,10 +336,11 @@ export const SpaceWorkspaceWidget = () => { sceneId: selectedRoomId, timerPresetId, soundPresetId: selectedPresetId, + goal: normalizedGoal, override: selectionOverride, }), ); - }, [selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride]); + }, [goalInput, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt, storedGoal]); return (
@@ -351,6 +372,24 @@ export const SpaceWorkspaceWidget = () => { onGoalChange={handleGoalChange} onGoalChipSelect={handleGoalChipSelect} onStart={handleStart} + resumeHint={ + showResumePrompt + ? { + goal: storedGoal, + onResume: () => { + setGoalInput(storedGoal); + setSelectedGoalId(null); + setShowResumePrompt(false); + setWorkspaceMode('focus'); + }, + onStartFresh: () => { + setGoalInput(''); + setSelectedGoalId(null); + setShowResumePrompt(false); + }, + } + : undefined + } />