From e68d5ca4dc4df2af65a57070747e8024ac95398a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:12:53 +0000 Subject: [PATCH] refactor(desktop): make task focus mode self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle editing focus entirely within TaskParticleView instead of threading editing state up to StreamView and special-casing the navigation hook. While editing, the card now registers a capture-phase key listener — the same pattern the app's other focus overlays use — that consumes Escape (blurring the field, which flushes the draft and resumes playback) and swallows stream-navigation keys so they can't pull focus to another particle. An "Esc to finish" hint shows while editing. This restores use-stream-navigation-keys to its original form and reduces the StreamView change to a single `immersive` prop. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM --- .../src/features/particles/stream-view.tsx | 10 --- .../features/particles/task-particle-view.tsx | 64 +++++++++++++++---- .../src/hooks/use-stream-navigation-keys.ts | 26 +------- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index e309990..319710f 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -277,7 +277,6 @@ 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) => { @@ -292,12 +291,6 @@ 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, @@ -306,8 +299,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { mediaRef, onExit: handleExitNavigate, onToggleViewMode: toggleViewMode, - editing: taskEditing, - onExitEditing: handleExitEditing, }); const handleOpenHuddle = useCallback(() => { @@ -459,7 +450,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { 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 2cb967f..8278eb5 100644 --- a/js/desktop/src/features/particles/task-particle-view.tsx +++ b/js/desktop/src/features/particles/task-particle-view.tsx @@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell'; import { useLiveDraftField } from '@/hooks/use-live-draft-field'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { resolveHumanDisplay } from '@/lib/humans'; +import { isTypingTarget } from '@/lib/keyboard'; +import { KeyHint } from '@/components/key-hint'; type TaskParticle = Extract; @@ -37,15 +39,28 @@ interface TaskParticleViewProps { paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; - /** Dim and blur the surrounding stream chrome while a field is focused. */ + /** + * Enable focus mode while a field is focused: dim and blur the surrounding + * stream, and trap stream keys (Escape leaves the editor, navigation keys + * stay put). Off in the standalone leaf view, which has no stream chrome. + */ immersive?: boolean; - /** Notify the parent when focus enters or leaves the card (focus mode). */ - onEditingChange?: (editing: boolean) => void; } const DWELL_DURATION_S = 8; const UNASSIGNED = 'unassigned'; +// Stream-navigation keys to swallow while editing so they can't pull focus to +// another particle (only when focus isn't already in a text field). +const NAV_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'l', + 'L', +]); + function useParticleDocPath( containerPath: ParticlePath, particleId: string, @@ -61,7 +76,6 @@ export function TaskParticleView({ onEnded, onProgress, immersive = false, - onEditingChange, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); @@ -80,19 +94,33 @@ 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(); }, []); + // While editing, the card acts like the app's other focus overlays: a + // capture-phase listener consumes Escape (which blurs the field — flushing + // the draft and resuming playback) and swallows stream-navigation keys, so + // the stream's own handlers never see them. Self-contained, so no editing + // state has to be threaded back up to the stream. + useEffect(() => { + if (!immersive || !editing) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + exitFocus(); + } else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) { + // Arrows still move the caret inside text fields; only block them when + // focus is on a non-text control (checkbox, assignee select). + e.stopPropagation(); + } + }; + window.addEventListener('keydown', onKeyDown, { capture: true }); + return () => + window.removeEventListener('keydown', onKeyDown, { capture: true }); + }, [immersive, editing, exitFocus]); + useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, @@ -282,6 +310,16 @@ export function TaskParticleView({ + {immersive && editing && ( + + to finish + + )} ); } diff --git a/js/desktop/src/hooks/use-stream-navigation-keys.ts b/js/desktop/src/hooks/use-stream-navigation-keys.ts index a1637dd..3f07340 100644 --- a/js/desktop/src/hooks/use-stream-navigation-keys.ts +++ b/js/desktop/src/hooks/use-stream-navigation-keys.ts @@ -12,17 +12,11 @@ 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, 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. + * Arrow keys (with shift+arrow seek), Escape. Skipped while playback is + * paused for any reason (overlay, hold-space, compose). */ export function useStreamNavigationKeys({ next, @@ -32,23 +26,9 @@ 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; @@ -96,7 +76,5 @@ export function useStreamNavigationKeys({ mediaRef, onExit, onToggleViewMode, - editing, - onExitEditing, ]); }