Add immersive focus mode for task editing (#300)

* 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM

* 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM

* fix style of keyhint

---------

Co-authored-by: Claude <[email protected]>
This commit was merged in pull request #300.
This commit is contained in:
Arjun Patel
2026-06-21 10:20:26 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f2f8602263
commit 428f9ea1c9
2 changed files with 71 additions and 1 deletions
@@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
paused={paused}
onEnded={handleParticleEnded}
onProgress={setProgress}
immersive
/>
);
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,11 +39,28 @@ interface TaskParticleViewProps {
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
/**
* 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;
}
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,
@@ -56,6 +75,7 @@ export function TaskParticleView({
paused,
onEnded,
onProgress,
immersive = false,
}: TaskParticleViewProps) {
const { networkId } = parseParticlePath(containerPath);
const network = useNetwork(networkId);
@@ -74,6 +94,33 @@ export function TaskParticleView({
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`);
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,
@@ -166,8 +213,20 @@ export function TaskParticleView({
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
{immersive && editing && (
<div
className="animate-in fade-in-0 fixed inset-0 z-30 bg-black/50 backdrop-blur-md duration-200"
onMouseDown={exitFocus}
aria-hidden
/>
)}
<div
className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"
className={cn(
'scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md transition-shadow',
immersive &&
editing &&
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-white/15',
)}
onFocusCapture={() => setEditing(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
@@ -251,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 -translate-x-1/2 text-xs text-white/40"
>
to finish
</KeyHint>
)}
</div>
);
}