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
This commit is contained in:
Claude
2026-06-21 16:47:16 +00:00
parent f2f8602263
commit 8b9e1905d1
3 changed files with 67 additions and 3 deletions
@@ -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:
@@ -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 (
<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);
@@ -12,11 +12,17 @@ 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. 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,
]);
}