refactor(desktop): make task focus mode self-contained

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM
This commit is contained in:
Claude
2026-06-21 17:12:53 +00:00
parent 8b9e1905d1
commit e68d5ca4dc
3 changed files with 53 additions and 47 deletions
@@ -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:
@@ -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<Particle, { type: 'task' }>;
@@ -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({
</Select>
</div>
</div>
{immersive && editing && (
<KeyHint
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"
>
to finish
</KeyHint>
)}
</div>
);
}
@@ -12,17 +12,11 @@ interface UseStreamNavigationKeysOptions {
mediaRef: RefObject<MediaParticleHandle | null>;
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,
]);
}