fix(space): 정리된 intent hud와 리뷰 반영

This commit is contained in:
2026-03-14 16:28:26 +09:00
parent 6154bd54a8
commit bc08a049b6
17 changed files with 746 additions and 214 deletions

View File

@@ -1,16 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { FloatingGoalWidget } from './FloatingGoalWidget';
import { GoalCompleteSheet } from './GoalCompleteSheet';
import { IntentCapsule } from './IntentCapsule';
import { NextMicroStepPrompt } from './NextMicroStepPrompt';
import { RefocusSheet } from './RefocusSheet';
interface SpaceFocusHudWidgetProps {
goal: string;
microStep?: string | null;
timerLabel: string;
timeDisplay?: string;
visible: boolean;
hasActiveSession?: boolean;
playbackState?: 'running' | 'paused';
sessionPhase?: 'focus' | 'break' | null;
@@ -21,7 +21,7 @@ interface SpaceFocusHudWidgetProps {
onStartRequested?: () => void;
onPauseRequested?: () => void;
onRestartRequested?: () => void;
onExitRequested?: () => void;
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
@@ -29,9 +29,7 @@ interface SpaceFocusHudWidgetProps {
export const SpaceFocusHudWidget = ({
goal,
microStep,
timerLabel,
timeDisplay,
visible,
hasActiveSession = false,
playbackState = 'paused',
sessionPhase = 'focus',
@@ -42,15 +40,25 @@ export const SpaceFocusHudWidget = ({
onStartRequested,
onPauseRequested,
onRestartRequested,
onExitRequested,
onIntentUpdate,
onGoalUpdate,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => {
const [sheetOpen, setSheetOpen] = useState(false);
const [isRefocusOpen, setRefocusOpen] = useState(false);
const [isMicroStepPromptOpen, setMicroStepPromptOpen] = useState(false);
const [draftGoal, setDraftGoal] = useState('');
const [draftMicroStep, setDraftMicroStep] = useState('');
const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal');
const [isSavingIntent, setSavingIntent] = useState(false);
const [intentError, setIntentError] = useState<string | null>(null);
const visibleRef = useRef(false);
const playbackStateRef = useRef<'running' | 'paused'>(playbackState);
const resumePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const pausePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const restReminderTimerRef = useRef<number | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
const isIntentOverlayOpen = isRefocusOpen || isMicroStepPromptOpen || sheetOpen;
useEffect(() => {
return () => {
@@ -62,44 +70,206 @@ export const SpaceFocusHudWidget = ({
}, []);
useEffect(() => {
if (visible && !visibleRef.current && playbackState === 'running') {
if (!visibleRef.current && playbackState === 'running') {
onStatusMessage({
message: copy.space.focusHud.goalToast(normalizedGoal),
});
}
visibleRef.current = visible;
}, [normalizedGoal, onStatusMessage, playbackState, visible]);
visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible) {
if (resumePlaybackStateRef.current === 'paused' && playbackState === 'running') {
onStatusMessage({
message: copy.space.focusHud.goalToast(normalizedGoal),
});
}
playbackStateRef.current = playbackState;
}, [normalizedGoal, onStatusMessage, playbackState, visible]);
resumePlaybackStateRef.current = playbackState;
}, [normalizedGoal, onStatusMessage, playbackState]);
if (!visible) {
return null;
}
const openRefocus = useCallback((field: 'goal' | 'microStep' = 'goal') => {
setDraftGoal(goal.trim());
setDraftMicroStep(normalizedMicroStep ?? '');
setAutoFocusField(field);
setIntentError(null);
setMicroStepPromptOpen(false);
setRefocusOpen(true);
}, [goal, normalizedMicroStep]);
useEffect(() => {
if (
pausePlaybackStateRef.current === 'running' &&
playbackState === 'paused' &&
hasActiveSession &&
!isRefocusOpen &&
!sheetOpen
) {
openRefocus('microStep');
onStatusMessage({
message: copy.space.focusHud.refocusOpenOnPause,
});
}
pausePlaybackStateRef.current = playbackState;
}, [hasActiveSession, isRefocusOpen, onStatusMessage, openRefocus, playbackState, sheetOpen]);
useEffect(() => {
if (normalizedMicroStep) {
return;
}
setMicroStepPromptOpen(false);
}, [normalizedMicroStep]);
const handleOpenCompleteSheet = () => {
setIntentError(null);
setRefocusOpen(false);
setMicroStepPromptOpen(false);
setSheetOpen(true);
};
const handleRefocusSubmit = async () => {
const trimmedGoal = draftGoal.trim();
if (!trimmedGoal || isSavingIntent) {
return;
}
setSavingIntent(true);
setIntentError(null);
try {
const didUpdate = await onIntentUpdate({
goal: trimmedGoal,
microStep: draftMicroStep.trim() || null,
});
if (!didUpdate) {
setIntentError(copy.space.workspace.intentSyncFailed);
return;
}
setRefocusOpen(false);
onStatusMessage({
message: copy.space.focusHud.refocusSaved,
});
} finally {
setSavingIntent(false);
}
};
const handleKeepGoalOnly = async () => {
if (isSavingIntent) {
return;
}
setSavingIntent(true);
setIntentError(null);
try {
const didUpdate = await onIntentUpdate({
microStep: null,
});
if (!didUpdate) {
setIntentError(copy.space.workspace.intentSyncFailed);
return;
}
setMicroStepPromptOpen(false);
onStatusMessage({
message: copy.space.focusHud.microStepCleared,
});
} finally {
setSavingIntent(false);
}
};
const handleDefineNextMicroStep = () => {
setDraftGoal(goal.trim());
setDraftMicroStep('');
setAutoFocusField('microStep');
setIntentError(null);
setMicroStepPromptOpen(false);
setRefocusOpen(true);
};
return (
<>
<FloatingGoalWidget
goal={goal}
microStep={microStep}
onGoalCompleteRequest={handleOpenCompleteSheet}
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase}
/>
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(26rem,calc(100vw-3rem))] md:left-10 md:top-9">
<IntentCapsule
goal={normalizedGoal}
microStep={microStep}
canRefocus={Boolean(hasActiveSession)}
canComplete={hasActiveSession && sessionPhase === 'focus'}
showActions={!isIntentOverlayOpen}
onOpenRefocus={() => openRefocus()}
onMicroStepDone={() => {
if (!normalizedMicroStep) {
openRefocus('microStep');
return;
}
setIntentError(null);
setRefocusOpen(false);
setMicroStepPromptOpen(true);
}}
onGoalCompleteRequest={handleOpenCompleteSheet}
/>
<RefocusSheet
open={isRefocusOpen}
goalDraft={draftGoal}
microStepDraft={draftMicroStep}
autoFocusField={autoFocusField}
isSaving={isSavingIntent}
error={intentError}
onGoalChange={setDraftGoal}
onMicroStepChange={setDraftMicroStep}
onClose={() => {
if (isSavingIntent) {
return;
}
setIntentError(null);
setRefocusOpen(false);
}}
onSubmit={() => {
void handleRefocusSubmit();
}}
/>
<NextMicroStepPrompt
open={isMicroStepPromptOpen}
isSubmitting={isSavingIntent}
error={intentError}
onKeepGoalOnly={() => {
void handleKeepGoalOnly();
}}
onDefineNext={handleDefineNextMicroStep}
/>
<GoalCompleteSheet
open={sheetOpen}
currentGoal={goal}
onClose={() => setSheetOpen(false)}
onRest={() => {
setSheetOpen(false);
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
}
restReminderTimerRef.current = window.setTimeout(() => {
onStatusMessage({ message: copy.space.focusHud.restReminder });
restReminderTimerRef.current = null;
}, 5 * 60 * 1000);
}}
onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
</div>
<SpaceTimerHudWidget
timerLabel={timerLabel}
timeDisplay={timeDisplay}
isImmersionMode
hasActiveSession={hasActiveSession}
@@ -114,26 +284,6 @@ export const SpaceFocusHudWidget = ({
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}
/>
<GoalCompleteSheet
open={sheetOpen}
currentGoal={goal}
onClose={() => setSheetOpen(false)}
onRest={() => {
setSheetOpen(false);
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
}
restReminderTimerRef.current = window.setTimeout(() => {
onStatusMessage({ message: copy.space.focusHud.restReminder });
restReminderTimerRef.current = null;
}, 5 * 60 * 1000);
}}
onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
</>
);
};