Add immersive focus mode for task editing #300

Merged
talksik merged 3 commits from claude/dreamy-brahmagupta-1gx4i1 into main 2026-06-21 17:20:26 +00:00
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>
);
}