From 8b9e1905d1c397406772b25f88ff7a2e04c4153d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 16:47:16 +0000 Subject: [PATCH 1/3] 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, ]); } -- 2.54.0 From e68d5ca4dc4df2af65a57070747e8024ac95398a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:12:53 +0000 Subject: [PATCH 2/3] 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, ]); } -- 2.54.0 From 641b3ebcb7bc2505f681b0421354203f5d7c1286 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 10:16:44 -0700 Subject: [PATCH 3/3] fix style of keyhint --- js/desktop/src/features/particles/task-particle-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/desktop/src/features/particles/task-particle-view.tsx b/js/desktop/src/features/particles/task-particle-view.tsx index 8278eb5..76c2209 100644 --- a/js/desktop/src/features/particles/task-particle-view.tsx +++ b/js/desktop/src/features/particles/task-particle-view.tsx @@ -315,7 +315,7 @@ export function TaskParticleView({ keys="Esc" onClick={exitFocus} title="Finish editing (or press Esc)" - className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 flex -translate-x-1/2 items-center rounded-full bg-black/40 px-3 py-1.5 text-white/70 backdrop-blur-sm hover:bg-black/60 hover:text-white" + className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 -translate-x-1/2 text-xs text-white/40" > to finish -- 2.54.0