From 1b01ceaa8b86b3ba56958e959662d1d2d27a9652 Mon Sep 17 00:00:00 2001 From: corpi Date: Sun, 15 Mar 2026 18:52:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(flow):=20paused=20resume=20gate=EC=99=80?= =?UTF-8?q?=20auto-resume=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/12_core_loop_execution_roadmap.md | 5 ++ docs/17_product_alignment_findings.md | 2 +- docs/session_brief.md | 13 +++-- docs/work.md | 4 +- .../ui/FocusDashboardWidget.tsx | 26 +++++++--- .../ui/SpaceFocusHudWidget.tsx | 13 +++++ .../ui/SpaceWorkspaceWidget.tsx | 52 ++++++++++++++++--- 7 files changed, 94 insertions(+), 21 deletions(-) diff --git a/docs/12_core_loop_execution_roadmap.md b/docs/12_core_loop_execution_roadmap.md index 1bc035e..247a550 100644 --- a/docs/12_core_loop_execution_roadmap.md +++ b/docs/12_core_loop_execution_roadmap.md @@ -369,6 +369,11 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가 - `/app`은 running session을 감지하면 hero 대신 즉시 `/space`로 보낸다 - `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다 - 다음 구현은 paused resume gate와 auto-resume handoff다 +- `Paused Session Re-entry` Slice 2-3 + - `/app` paused 상태에 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 올렸다 + - explicit continue 이후 `/space`는 자동 resume된다 + - refocus handoff는 `/space?resume=refocus`로 들어가며, 진입 직후 refocus tray를 연다 + - 다음 구현은 takeover flow다 --- diff --git a/docs/17_product_alignment_findings.md b/docs/17_product_alignment_findings.md index 27dab44..bbf071c 100644 --- a/docs/17_product_alignment_findings.md +++ b/docs/17_product_alignment_findings.md @@ -67,7 +67,7 @@ Last Updated: 2026-03-15 | ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 | | ALN-008 | P1 | `잠시 비우기`와 `Break`의 제품 의미 | break는 reward/reset, pause는 recovery로 분리돼야 함 | 현재는 카피와 트레이는 개선됐지만, 제품 차원의 최종 정의와 시각 분리까지 완전히 닫히진 않았음 | `docs/10_refocus_system_spec.md`, `docs/11_away_return_recovery_spec.md`, `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/widgets/space-focus-hud/ui/ReturnPrompt.tsx` | open | `잠시 비우기`, active break, return(break)를 하나의 최종 state model로 재정의 | | ALN-009 | P3 | Spec / current-state drift | 다음 세션 문서가 실제 구현과 맞아야 함 | intent card, goal complete, review entry 관련 오래된 표현이 여러 spec에 남아 있었음 | `docs/10_refocus_system_spec.md`, `docs/13_space_intent_card_collapsed_expanded_spec.md`, `docs/90_current_state.md`, `docs/session_brief.md`, `../../current_context.md` | fixed-awaiting-browser | 이후 라운드부터는 fix와 문서 갱신을 같은 커밋에서 닫는지 점검 | -| ALN-010 | P1 | paused session 재진입 정책 | running은 바로 `/space`, paused는 `/app` resume gate, explicit continue 이후에는 자동 resume이어야 함 | Session Routing Contract는 구현됐지만, paused resume gate / auto-resume handoff / takeover flow가 아직 남아 있어 최종 정책이 완전히 닫히진 않았음 | `docs/18_paused_session_reentry_spec.md`, `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`, `src/features/focus-session/model/useFocusSessionEngine.ts` | open | Slice 2-4를 이어서 구현하고 browser QA로 닫기 | +| ALN-010 | P1 | paused session 재진입 정책 | running은 바로 `/space`, paused는 `/app` resume gate, explicit continue 이후에는 자동 resume이어야 함 | Session Routing Contract, paused resume gate, auto-resume handoff는 구현됐지만 takeover flow와 browser QA가 아직 남아 있어 최종 정책이 완전히 닫히진 않았음 | `docs/18_paused_session_reentry_spec.md`, `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`, `src/features/focus-session/model/useFocusSessionEngine.ts` | open | takeover flow를 구현하고 paused re-entry browser QA로 닫기 | --- diff --git a/docs/session_brief.md b/docs/session_brief.md index 453e55b..0997857 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -14,11 +14,10 @@ Last Updated: 2026-03-15 ## 현재 우선순위 -1. `/app` Paused Resume Gate -2. `/space` Auto-Resume Handoff -3. `Paused Session Takeover Flow` -4. `Core Loop Alignment` browser audit -5. `Weekly Review` recovery 집계 연결 +1. `Paused Session Takeover Flow` +2. `Core Loop Alignment` browser audit +3. `Weekly Review` recovery 집계 연결 +4. `Premium Ambience` ## 최근 세션 상태 @@ -111,6 +110,10 @@ Last Updated: 2026-03-15 - `/app`은 running session을 감지하면 hero를 보여주지 않고 즉시 `/space`로 보낸다. - `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다. - `/app`의 `이어서 들어가기`는 다음 slice를 위해 `/space?resume=continue` handoff를 사용한다. +- `Paused Resume Gate`와 `Auto-Resume Handoff`를 구현했다. + - paused 상태의 `/app`은 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 함께 보여준다. + - `이어서 몰입하기`는 `/space?resume=continue`로 들어간 뒤 자동 resume된다. + - `한 조각 다시 잡기`는 `/space?resume=refocus`로 들어간 뒤 refocus tray를 바로 연다. - `Product Alignment Audit` 운영을 시작했다. - `16_product_alignment_audit_plan.md`를 기준 문서로 추가했다. - `17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다. diff --git a/docs/work.md b/docs/work.md index 5f01d36..3ac10f0 100644 --- a/docs/work.md +++ b/docs/work.md @@ -60,7 +60,7 @@ - paused 사용자가 2초 안에 다음 행동을 이해할 수 있다 - review는 보이지만 resume보다 앞서지 않는다 - 진행 상태: - - 다음 작업 + - 완료 - 검증: - `/app` paused state browser QA - 커밋 힌트: @@ -81,6 +81,8 @@ - 완료 조건: - `/app -> /space -> start` 이중 클릭이 사라진다 - explicit continue 이후에는 `/space`에서 바로 실행 상태로 들어간다 +- 진행 상태: + - 완료 - 검증: - paused -> continue 브라우저 QA - 커밋 힌트: diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index 1cc2d4c..f762c58 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -42,9 +42,11 @@ const entryCopy = { resumeEyebrow: 'Resume', resumeRunning: '진행 중인 세션이 있어요.', resumePaused: '잠시 멈춘 세션이 있어요.', - resumeCta: '이어서 들어가기', + resumeCta: '이어서 몰입하기', + resumeRefocusCta: '한 조각 다시 잡기', resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.', resumeMicroStepLabel: '마지막 한 조각', + resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.', resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.', loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.', reviewEyebrow: 'Weekly Review', @@ -77,6 +79,8 @@ const inputShellClass = 'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]'; const primaryButtonClass = 'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48'; +const secondaryButtonClass = + 'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]'; const timerLabelById: Record = { '25-5': '25/5', @@ -262,6 +266,10 @@ export const FocusDashboardWidget = () => { router.push('/space?resume=continue'); }; + const handleResumeRefocus = () => { + router.push('/space?resume=refocus'); + }; + const shouldShowWeeklyReviewTeaser = !isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn; const shouldShowResumeReviewEntry = @@ -344,13 +352,19 @@ export const FocusDashboardWidget = () => {
- -

{activeRitualMeta}

+
+ + +
+

{activeRitualMeta}

-

{entryCopy.resumeNewGoalHint}

+

{entryCopy.resumePausedHint}

+

{entryCopy.resumeNewGoalHint}

{shouldShowResumeReviewEntry ? ( void; onStartRequested?: () => void; onPauseRequested?: () => void; onRestartRequested?: () => void; @@ -42,7 +44,9 @@ export const SpaceFocusHudWidget = ({ canStartSession = false, canPauseSession = false, canRestartSession = false, + entryOverlayIntent = null, returnPromptMode = null, + onEntryOverlayIntentHandled, onStartRequested, onPauseRequested, onRestartRequested, @@ -176,6 +180,15 @@ export const SpaceFocusHudWidget = ({ } }, [normalizedMicroStep, overlay]); + useEffect(() => { + if (entryOverlayIntent !== 'resume-refocus' || !hasActiveSession || overlay !== 'none') { + return; + } + + openRefocus('microStep', 'manual'); + onEntryOverlayIntentHandled?.(); + }, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, openRefocus, overlay]); + const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => { setIntentError(null); setCompletePreferredView(preferredView); diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 4ad808c..712d82e 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -104,6 +104,7 @@ export const SpaceWorkspaceWidget = () => { const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = useState("space-setup"); const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false); + const [hasConsumedEntryOverlayIntent, setHasConsumedEntryOverlayIntent] = useState(false); const { selectedPresetId, @@ -209,6 +210,7 @@ export const SpaceWorkspaceWidget = () => { setSelectedGoalId: selection.setSelectedGoalId, setShowResumePrompt: selection.setShowResumePrompt, }); + const handleStartRequested = controls.handleStartRequested; const awayReturnRecovery = useAwayReturnRecovery({ currentSession, @@ -226,6 +228,13 @@ export const SpaceWorkspaceWidget = () => { const allowsPausedReentry = resumeIntent === "continue" || resumeIntent === "refocus"; const didResolveEntryRouteRef = useRef(false); + const didHandleResumeIntentRef = useRef(false); + const entryOverlayIntent = + !hasConsumedEntryOverlayIntent && + resumeIntent === "refocus" && + currentSession?.state === "paused" + ? "resume-refocus" + : null; const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser ? { title: isPro @@ -262,6 +271,28 @@ export const SpaceWorkspaceWidget = () => { } }, [allowsPausedReentry, currentSession, isBootstrapping, router]); + useEffect(() => { + if ( + isBootstrapping || + !currentSession || + currentSession.state !== "paused" || + didHandleResumeIntentRef.current + ) { + return; + } + + if (resumeIntent === "continue") { + didHandleResumeIntentRef.current = true; + router.replace("/space"); + void handleStartRequested(); + return; + } + + if (resumeIntent === "refocus") { + return; + } + }, [currentSession, handleStartRequested, isBootstrapping, resumeIntent, router]); + useEffect(() => { const preferMobile = typeof window !== "undefined" @@ -355,14 +386,19 @@ export const SpaceWorkspaceWidget = () => { hasActiveSession={Boolean(currentSession)} playbackState={resolvedPlaybackState} sessionPhase={phase ?? 'focus'} - isSessionActionPending={isSessionMutating} - canStartSession={controls.canStartSession} - canPauseSession={controls.canPauseSession} - canRestartSession={controls.canRestartSession} - returnPromptMode={awayReturnRecovery.returnPromptMode} - onStartRequested={() => { - void controls.handleStartRequested(); - }} + isSessionActionPending={isSessionMutating} + canStartSession={controls.canStartSession} + canPauseSession={controls.canPauseSession} + canRestartSession={controls.canRestartSession} + entryOverlayIntent={entryOverlayIntent} + returnPromptMode={awayReturnRecovery.returnPromptMode} + onEntryOverlayIntentHandled={() => { + setHasConsumedEntryOverlayIntent(true); + router.replace("/space"); + }} + onStartRequested={() => { + void handleStartRequested(); + }} onPauseRequested={() => { void controls.handlePauseRequested(); }}