From 8b9e1905d1c397406772b25f88ff7a2e04c4153d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 16:47:16 +0000 Subject: [PATCH] feat(desktop): immersive focus mode for editing tasks Editing a task in the stream was ambiguous: nothing signalled that you were editing, playback chrome stayed live, Escape exited the whole stream, and arrow keys could navigate to another particle mid-edit. Focusing any field now enters a focus mode: the surrounding stream chrome dims and blurs while the task card lifts above it with a soft ring, so the task you're editing is unmistakable. Escape (or clicking outside the card) leaves the editor and resumes playback from where it paused instead of exiting the stream, and arrow/list keys no longer pull focus to another particle while editing. Text particles already get this via their full-screen edit overlay, so the two editing flows are now consistent. Addresses #286 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM --- .../src/features/particles/stream-view.tsx | 11 +++++++ .../features/particles/task-particle-view.tsx | 33 ++++++++++++++++++- .../src/hooks/use-stream-navigation-keys.ts | 26 +++++++++++++-- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 6abe7e3..e309990 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -277,6 +277,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id); const [showKeybindings, setShowKeybindings] = useState(false); const [textReactionOpen, setTextReactionOpen] = useState(false); + const [taskEditing, setTaskEditing] = useState(false); const handleSubmitTextReaction = useCallback( (text: string) => { @@ -291,6 +292,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { navigate(`/${networkId}`); }, [navigate, networkId]); + // Leaving task edit focus: blur the active field, which clears the card's + // editing state and resumes playback. + const handleExitEditing = useCallback(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }, []); + useStreamNavigationKeys({ next, prev, @@ -299,6 +306,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { mediaRef, onExit: handleExitNavigate, onToggleViewMode: toggleViewMode, + editing: taskEditing, + onExitEditing: handleExitEditing, }); const handleOpenHuddle = useCallback(() => { @@ -449,6 +458,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { paused={paused} onEnded={handleParticleEnded} onProgress={setProgress} + immersive + onEditingChange={setTaskEditing} /> ); default: diff --git a/js/desktop/src/features/particles/task-particle-view.tsx b/js/desktop/src/features/particles/task-particle-view.tsx index cc73772..2cb967f 100644 --- a/js/desktop/src/features/particles/task-particle-view.tsx +++ b/js/desktop/src/features/particles/task-particle-view.tsx @@ -37,6 +37,10 @@ interface TaskParticleViewProps { paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; + /** Dim and blur the surrounding stream chrome while a field is focused. */ + immersive?: boolean; + /** Notify the parent when focus enters or leaves the card (focus mode). */ + onEditingChange?: (editing: boolean) => void; } const DWELL_DURATION_S = 8; @@ -56,6 +60,8 @@ export function TaskParticleView({ paused, onEnded, onProgress, + immersive = false, + onEditingChange, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); @@ -74,6 +80,19 @@ export function TaskParticleView({ const [editing, setEditing] = useState(false); useSuspendPlayback(editing, `task-edit-${particle.id}`); + // Mirror focus state to the stream so it can lock navigation and route + // Escape to "leave the editor" instead of "exit the stream". Reset on + // unmount so a particle change mid-edit doesn't strand the stream in + // focus mode. + useEffect(() => { + onEditingChange?.(editing); + return () => onEditingChange?.(false); + }, [editing, onEditingChange]); + + const exitFocus = useCallback(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }, []); + useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, @@ -166,8 +185,20 @@ export function TaskParticleView({ return (
+ {immersive && editing && ( +
+ )}
setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); diff --git a/js/desktop/src/hooks/use-stream-navigation-keys.ts b/js/desktop/src/hooks/use-stream-navigation-keys.ts index 3f07340..a1637dd 100644 --- a/js/desktop/src/hooks/use-stream-navigation-keys.ts +++ b/js/desktop/src/hooks/use-stream-navigation-keys.ts @@ -12,11 +12,17 @@ interface UseStreamNavigationKeysOptions { mediaRef: RefObject; onExit: () => void; onToggleViewMode: () => void; + /** True while the current particle is being edited (focus mode). */ + editing: boolean; + /** Leave edit focus mode (blur the field) and resume playback. */ + onExitEditing: () => void; } /** - * Arrow keys (with shift+arrow seek), Escape. Skipped while playback is - * paused for any reason (overlay, hold-space, compose). + * Arrow keys (with shift+arrow seek), Escape, and list-view toggle. While a + * particle is being edited the stream enters focus mode: Escape leaves the + * editor (resuming playback) and the other keys are suppressed so they can't + * navigate away mid-edit. */ export function useStreamNavigationKeys({ next, @@ -26,9 +32,23 @@ export function useStreamNavigationKeys({ mediaRef, onExit, onToggleViewMode, + editing, + onExitEditing, }: UseStreamNavigationKeysOptions) { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { + // While editing the current particle the stream is in a focused state: + // Escape leaves the editor and resumes playback instead of exiting the + // stream, and navigation keys stay put rather than pulling focus to a + // different particle. + if (editing) { + if (e.key === 'Escape') { + e.preventDefault(); + onExitEditing(); + } + return; + } + if (isTypingTarget(e)) return; const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1; @@ -76,5 +96,7 @@ export function useStreamNavigationKeys({ mediaRef, onExit, onToggleViewMode, + editing, + onExitEditing, ]); }