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
3 changed files with 67 additions and 3 deletions
Showing only changes of commit 8b9e1905d1 - Show all commits
@@ -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,
]);
}