feat: stream list view and tasks #279

Merged
talksik merged 7 commits from ecosystem-prototype into main 2026-06-12 19:26:50 +00:00
37 changed files with 2384 additions and 791 deletions
+41 -12
View File
@@ -146,14 +146,21 @@ export const TextPropertiesSchema = z.object({
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
export const ChecklistItemSchema = z.object({
text: z.string(),
done: z.boolean(),
});
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
export const TaskPropertiesSchema = z.object({
title: z.string(),
description: z.string(),
status: z.string().optional(),
notes: z.string().optional(),
checklist: z.array(ChecklistItemSchema).optional(),
// humanId
assigned_to: z.string().optional(),
done: z.boolean(),
});
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
export const PaperPropertiesSchema = z.object({
title: z.string(),
@@ -194,7 +201,7 @@ export interface ParticlePropertiesMap {
media: MediaProperties;
file: FileProperties;
text: TextProperties;
quest: QuestProperties;
task: TaskProperties;
paper: PaperProperties;
}
@@ -211,6 +218,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
ParticleBaseSchema.extend({
type: z.literal('stream'),
properties: StreamPropertiesSchema,
// Open/closed lifecycle. Optional because some streams were created while
// the field was dropped — treat missing as 'open' (see isStreamOpen).
status: z.enum(['open', 'closed']).optional(),
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()),
@@ -221,7 +231,6 @@ export const ParticleSchema = z.discriminatedUnion('type', [
last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(['open', 'closed']).optional(),
}),
ParticleBaseSchema.extend({
type: z.literal('folder'),
@@ -229,6 +238,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()),
// Set to created_at on creation, bumped when children are added — keeps
// folders present in activity-ordered container queries.
last_child_created_at: z.coerce.date().optional(),
}),
ParticleBaseSchema.extend({
type: z.literal('media'),
@@ -248,8 +260,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
...TombstoneFields,
}),
ParticleBaseSchema.extend({
type: z.literal('quest'),
properties: QuestPropertiesSchema,
type: z.literal('task'),
properties: TaskPropertiesSchema,
reactions: ReactionsSchema,
...TombstoneFields,
}),
ParticleBaseSchema.extend({
@@ -259,17 +272,28 @@ export const ParticleSchema = z.discriminatedUnion('type', [
}),
]);
export type Particle = z.infer<typeof ParticleSchema>;
// Placeholder for docs whose `type` this client version doesn't recognize
// (e.g. a newer client wrote a particle type we don't ship yet). The converter
// maps them here instead of throwing, so they degrade to an "unsupported"
// view rather than breaking the whole subscription.
export const UnknownParticleSchema = ParticleBaseSchema.extend({
type: z.literal('unknown'),
raw_type: z.string(),
});
export type UnknownParticle = z.infer<typeof UnknownParticleSchema>;
export type ParticleType = Particle['type'];
export type Particle = z.infer<typeof ParticleSchema> | UnknownParticle;
/** Particle types this client can read and write — excludes 'unknown'. */
export type ParticleType = z.infer<typeof ParticleSchema>['type'];
/** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
export const CONTAINER_TYPES: ReadonlySet<Particle['type']> = new Set([
'stream',
'folder',
]);
export function isContainerType(type: ParticleType): boolean {
export function isContainerType(type: Particle['type']): boolean {
return CONTAINER_TYPES.has(type);
}
@@ -278,6 +302,11 @@ export function isParticleDeleted(particle: Particle): boolean {
return 'deleted_at' in particle && particle.deleted_at != null;
}
/** Missing status counts as open (streams created while the field was dropped). */
export function isStreamOpen(stream: Extract<Particle, { type: 'stream' }>) {
return stream.status !== 'closed';
}
// --- LiveKit types ---
export const GetLivekitTokenResponseSchema = z.object({
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -19,7 +19,9 @@ import { RecordingOverlay } from '@/features/compose/recording-overlay';
import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step';
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
import { TaskComposeStep } from '@/features/compose/task-compose-step';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import type { TaskProperties } from '@/api/types';
import { apiClient } from '@/api/client';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { useMediaDevicesStore } from '@/stores/media-devices-store';
@@ -40,11 +42,14 @@ export type ComposeStep =
| 'recording'
| 'reviewing'
| 'typing'
| 'task'
| 'configuring'
| 'submitting';
type RecordingSource = 'media' | 'screen';
type PendingArtifact = { type: 'task'; properties: TaskProperties };
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
@@ -52,8 +57,6 @@ interface ComposeOverlayProps {
onActiveChange?: (active: boolean) => void;
onStepChange?: (step: ComposeStep) => void;
onParticleCreated?: (particleId: string) => void;
/** When true, composing is blocked (e.g. stream is closed). */
disabled?: boolean;
}
const HOLD_THRESHOLD_MS = 250;
@@ -68,7 +71,6 @@ export function ComposeOverlay({
onActiveChange,
onStepChange,
onParticleCreated,
disabled,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>('idle');
const [error, setError] = useState<string | null>(null);
@@ -98,14 +100,14 @@ export function ComposeOverlay({
// Latest props/state for synchronous reads in keyboard handlers.
const stepRef = useRef(step);
const recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
const quotaExhaustedRef = useRef(quotaExhausted);
const recordingSourceRef = useRef(recordingSource);
const pendingArtifactRef = useRef<PendingArtifact | null>(null);
useEffect(() => {
disabledRef.current = disabled;
quotaExhaustedRef.current = quotaExhausted;
recordingSourceRef.current = recordingSource;
}, [disabled, quotaExhausted, recordingSource]);
}, [quotaExhausted, recordingSource]);
const setStepSync = useCallback((next: ComposeStep) => {
stepRef.current = next;
@@ -142,6 +144,7 @@ export function ComposeOverlay({
setReviewDurationMs(0);
setReviewMimeType(null);
setRecordingSource('media');
pendingArtifactRef.current = null;
setAttachments((prev) => {
revokeAttachmentThumbnails(prev);
return [];
@@ -330,7 +333,15 @@ export function ComposeOverlay({
if (!userId) return;
let particleId: undefined | string;
if (textContent.trim()) {
const pendingArtifact = pendingArtifactRef.current;
if (pendingArtifact) {
particleId = await createParticle.mutateAsync({
path,
type: pendingArtifact.type,
properties: pendingArtifact.properties,
createdByHumanId: userId,
});
} else if (textContent.trim()) {
particleId = await createParticle.mutateAsync({
path,
type: 'text',
@@ -454,14 +465,10 @@ export function ComposeOverlay({
// --- Compose intent handlers ---
// Single source of truth for the step transitions triggered by the user.
// Both the keyboard handler and the intent store dispatch into these so
// guards (disabled, quota) and screen-vs-media branching live in one place.
// guards (quota) and screen-vs-media branching live in one place.
const guardIdle = useCallback((): boolean => {
if (stepRef.current !== 'idle') return false;
if (disabledRef.current) {
toast.info('This stream is closed');
return false;
}
if (quotaExhaustedRef.current) {
toast.info(
'Daily message limit reached. Upgrade to Pro to keep sending.',
@@ -484,6 +491,26 @@ export function ComposeOverlay({
setStepSync('typing');
}, [guardIdle, setStepSync]);
const handleTaskIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('task');
}, [guardIdle, setStepSync]);
// Artifact submit: capture the artifact, then reuse the standard flow —
// reply mode creates it under targetPath, root mode configures a stream
// that will hold it as its first child.
const handleArtifactSubmit = useCallback(
(artifact: PendingArtifact) => {
pendingArtifactRef.current = artifact;
if (targetPath) {
void onSubmitReply();
} else {
setStepSync('configuring');
}
},
[targetPath, onSubmitReply, setStepSync],
);
const handleStopIntent = useCallback(() => {
if (stepRef.current !== 'recording') return;
if (recordingSourceRef.current === 'screen') {
@@ -537,6 +564,9 @@ export function ComposeOverlay({
case 'text':
handleTextIntent();
break;
case 'task':
handleTaskIntent();
break;
case 'stop':
handleStopIntent();
break;
@@ -552,6 +582,7 @@ export function ComposeOverlay({
}, [
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
@@ -566,6 +597,7 @@ export function ComposeOverlay({
if (
currentStep === 'typing' ||
currentStep === 'task' ||
currentStep === 'configuring' ||
currentStep === 'picking'
) {
@@ -599,6 +631,9 @@ export function ComposeOverlay({
} else if (e.key === 't' || e.key === 'T') {
e.preventDefault();
handleTextIntent();
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
handleTaskIntent();
}
break;
}
@@ -666,6 +701,7 @@ export function ComposeOverlay({
guardIdle,
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
@@ -774,8 +810,17 @@ export function ComposeOverlay({
dropZoneProps={dropZoneProps}
/>
)}
{step === 'task' && (
<TaskComposeStep
networkId={networkId}
onCancel={cancel}
onSubmit={(properties) =>
handleArtifactSubmit({ type: 'task', properties })
}
/>
)}
{!targetPath && step === 'configuring' && (
<ConfigureStreamStep
<ConfigureContainerStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
@@ -10,17 +10,20 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
interface ConfigureStreamStepProps {
interface ConfigureContainerStepProps {
/** Drives labels and hints; the form is identical for both kinds. */
kind?: 'stream' | 'folder';
networkId: string | null;
onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
onSubmit: (name: string, visibleTo: string[]) => void;
}
export function ConfigureStreamStep({
export function ConfigureContainerStep({
kind = 'stream',
networkId,
onCancel,
onSubmit,
}: ConfigureStreamStepProps) {
}: ConfigureContainerStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
@@ -79,9 +82,10 @@ export function ConfigureStreamStep({
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Label className="mb-1 text-xs text-white/50">
{kind === 'folder' ? 'Folder name' : 'Stream name'}
</Label>
<Input
type="text"
autoFocus
@@ -165,7 +169,7 @@ export function ConfigureStreamStep({
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create stream (or press ${metaKey}+Enter)`}
title={`Create ${kind} (or press ${metaKey}+Enter)`}
>
create
</KeyHint>
@@ -0,0 +1,125 @@
import { useCallback, useState } from 'react';
import type { TaskProperties } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks';
import { metaKey } from '@/lib/platform';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { KeyHint } from '@/components/key-hint';
const UNASSIGNED = 'unassigned';
interface TaskComposeStepProps {
networkId: string;
onCancel: () => void;
onSubmit: (properties: TaskProperties) => void;
}
/**
* Minimal task creation form. Checklist items are added after creation in
* the always-editable task view, keeping this step a quick capture.
*/
export function TaskComposeStep({
networkId,
onCancel,
onSubmit,
}: TaskComposeStepProps) {
const network = useNetwork(networkId);
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [assignedTo, setAssignedTo] = useState<string>(UNASSIGNED);
const handleSubmit = useCallback(() => {
const trimmed = title.trim();
if (!trimmed) return;
onSubmit({
title: trimmed,
...(notes.trim() && { notes: notes.trim() }),
...(assignedTo !== UNASSIGNED && { assigned_to: assignedTo }),
checklist: [],
done: false,
});
}, [title, notes, assignedTo, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[onCancel, handleSubmit],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div>
<Label className="mb-1 text-xs text-white/50">Task</Label>
<Input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to get done?"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional details…"
className="min-h-20 border-white/10 bg-white/5 text-white placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0 dark:bg-white/5"
/>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Assign to</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}>
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{network?.humans?.map((human) => (
<SelectItem key={human.id} value={human.id}>
{human.email_prefix}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create task (or press ${metaKey}+Enter)`}
>
create
</KeyHint>
</div>
</div>
);
}
+1 -19
View File
@@ -16,30 +16,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useNetworks } from '@/hooks/use-networks';
import { particlePath } from '@/lib/particle-path';
import { useParticle } from '@/hooks/use-particle';
import type { Particle } from '@/api/types';
import { getParticleDisplayName } from '@/lib/particle-display';
import { PropsWithChildren, useCallback } from 'react';
import { useDockBadge } from '@/hooks/use-dock-badge';
import { toast } from 'sonner';
import { RouteErrorBoundary } from '@/components/app-error-boundary';
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
}
}
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
+5 -116
View File
@@ -1,126 +1,15 @@
import { useCallback, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { CircleDot, CircleCheckBig } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { particlePath } from '@/lib/particle-path';
import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useStreamParticles } from '@/hooks/use-stream-particles';
import { useStreamKeyboardNav } from '@/hooks/use-stream-keyboard-nav';
import { ContainerView } from '@/features/particles/container-view';
/**
* Route-level component for /:networkId (index).
* Shows root-level particles for the selected network.
* Route-level component for /:networkId (index). The network root behaves
* like a folder: a container of streams, folders, and loose particles.
*/
export default function NetworkRoot() {
const { networkId } = useParams();
if (!networkId)
throw new Error('NetworkRoot requires a :networkId route param');
const navigate = useNavigate();
const path = particlePath(networkId, []);
const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: 'open' | 'closed' =
searchParams.get('status') === 'closed' ? 'closed' : 'open';
const setStatusTab = (next: 'open' | 'closed') => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('status', next);
return params;
},
{ replace: true },
);
};
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(
path,
{
status: statusTab,
},
);
const { selectedIndex } = useStreamKeyboardNav({
streams,
enabled: !composeActive,
onNavigate: useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
),
});
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center p-1 border-b">
<Tabs
value={statusTab}
onValueChange={(v) =>
setStatusTab(v === 'closed' ? 'closed' : 'open')
}
>
<TabsList>
<TabsTrigger value="open">
<CircleDot className="size-3 text-green-500" /> Open
</TabsTrigger>
<TabsTrigger value="closed">
<CircleCheckBig className="size-3" /> Closed
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
<ParticleListView
streams={streams}
networkId={networkId}
isLoading={isLoading}
selectedIndex={selectedIndex}
canLoadMore={canLoadMore}
onLoadMore={loadMore}
/>
</div>
<ComposeOverlay networkId={networkId} onActiveChange={setComposeActive} />
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<NetworkRootControls />
</div>
</div>
</div>
);
}
function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Start recording (or hold `)"
>
to start
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Compose text (or press T)"
>
text
</KeyHint>
</div>
);
return <ContainerView path={particlePath(networkId, [])} />;
}
@@ -0,0 +1,269 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CircleCheckBig, CircleDot, FolderIcon } from 'lucide-react';
import type { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { useAuthStore } from '@/stores/auth-store';
import { useContainerChildren } from '@/hooks/use-container-children';
import { useListKeyboardNav } from '@/hooks/use-list-keyboard-nav';
import {
useCreateParticle,
useCreateStreamParticle,
} from '@/hooks/use-create-particle';
import { ParticleChildrenList } from '@/features/particles/particle-children-list';
import { ComposeOverlay } from '@/features/compose/compose-overlay';
import { ComposeQuotaIndicator } from '@/features/compose/compose-quota-indicator';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
type ContainerKind = 'stream' | 'folder';
interface ContainerViewProps {
/** Path of the container; network root when it has no segments. */
path: ParticlePath;
/** Present when the container is a folder particle (drives the header). */
folderParticle?: Particle & { type: 'folder' };
}
/**
* Browsable view of a container's children — used for both the network root
* and folders, which share the same structure: a mixed-type child list, the
* compose overlay, and one keyboard grammar. The root is just a folder
* without a doc.
*/
export function ContainerView({ path, folderParticle }: ContainerViewProps) {
const { networkId, segments } = parseParticlePath(path);
const isRoot = segments.length === 0;
const navigate = useNavigate();
const userId = useAuthStore((s) => s.user?.id);
const [composeActive, setComposeActive] = useState(false);
const [creating, setCreating] = useState<ContainerKind | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: 'open' | 'closed' =
searchParams.get('status') === 'closed' ? 'closed' : 'open';
const setStatusTab = (next: 'open' | 'closed') => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('status', next);
return params;
},
{ replace: true },
);
};
// Folders are shelved for now: the root lists streams only, split by
// open/closed status (server-side filter, like before folders). Folder
// containers keep the mixed-type child list so the recursive container
// model can be revived later.
const { items, isLoading, canLoadMore, loadMore } = useContainerChildren(
path,
{ streamStatus: isRoot ? statusTab : undefined },
);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
const handleOpen = useCallback(
(particleId: string) => {
navigate(`/${networkId}/${[...segments, particleId].join('/')}`);
},
[navigate, networkId, segments],
);
const { selectedIndex } = useListKeyboardNav({
items,
enabled: !composeActive && creating === null,
onOpen: handleOpen,
});
// N creates a stream inside a folder. Root stream creation goes through the
// compose flow.
useEffect(() => {
if (isRoot || composeActive || creating !== null) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setCreating('stream');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isRoot, composeActive, creating]);
const handleCreateContainer = useCallback(
async (name: string, visibleTo: string[]) => {
if (!userId || !creating) return;
const kind = creating;
setCreating(null);
const id =
kind === 'folder'
? await createParticle.mutateAsync({
path,
type: 'folder',
properties: { name },
createdByHumanId: userId,
visibleTo,
})
: await createStream.mutateAsync({
networkId,
parentPath: path,
properties: { name },
createdByHumanId: userId,
visibleTo,
});
handleOpen(id);
},
coderabbitai[bot] commented 2026-06-12 18:14:18 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major | Quick win

Close the create step only after mutation succeeds.

On Line 112, setCreating(null) runs before the async create call. A failed mutation closes the modal and drops the user out of the flow.

Proposed fix
   const handleCreateContainer = useCallback(
     async (name: string, visibleTo: string[]) => {
       if (!userId || !creating) return;
       const kind = creating;
-      setCreating(null);
       const id =
         kind === 'folder'
           ? await createParticle.mutateAsync({
               path,
               type: 'folder',
               properties: { name },
               createdByHumanId: userId,
               visibleTo,
             })
           : await createStream.mutateAsync({
               networkId,
               parentPath: path,
               properties: { name },
               createdByHumanId: userId,
               visibleTo,
             });
+      setCreating(null);
       handleOpen(id);
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/features/particles/container-view.tsx` around lines 109 - 130,
The modal is closed before the async create call completes because
setCreating(null) is called prior to awaiting
createParticle.mutateAsync/createStream.mutateAsync; move setCreating(null) to
after the await so the create step only closes on success, and wrap the mutation
in try/catch around the awaits (in the anonymous async handler where
`creating`/`setCreating` are used) so on error you leave `creating` intact or
reset appropriately and surface the error (e.g., rethrow or show an error)
before calling handleOpen(id).
_⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_ **Close the create step only after mutation succeeds.** On Line 112, `setCreating(null)` runs before the async create call. A failed mutation closes the modal and drops the user out of the flow. <details> <summary>Proposed fix</summary> ```diff const handleCreateContainer = useCallback( async (name: string, visibleTo: string[]) => { if (!userId || !creating) return; const kind = creating; - setCreating(null); const id = kind === 'folder' ? await createParticle.mutateAsync({ path, type: 'folder', properties: { name }, createdByHumanId: userId, visibleTo, }) : await createStream.mutateAsync({ networkId, parentPath: path, properties: { name }, createdByHumanId: userId, visibleTo, }); + setCreating(null); handleOpen(id); }, ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/features/particles/container-view.tsx` around lines 109 - 130, The modal is closed before the async create call completes because setCreating(null) is called prior to awaiting createParticle.mutateAsync/createStream.mutateAsync; move setCreating(null) to after the await so the create step only closes on success, and wrap the mutation in try/catch around the awaits (in the anonymous async handler where `creating`/`setCreating` are used) so on error you leave `creating` intact or reset appropriately and surface the error (e.g., rethrow or show an error) before calling handleOpen(id). ``` </details> <!-- fingerprinting:phantom:poseidon:hawk --> <!-- cr-comment:v1:24edae63b996c2744eef5d54 --> <!-- This is an auto-generated comment by CodeRabbit -->
[
userId,
creating,
path,
networkId,
createParticle,
createStream,
handleOpen,
],
);
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{isRoot && (
<div className="flex shrink-0 items-center border-b p-1">
<Tabs
value={statusTab}
onValueChange={(v) =>
setStatusTab(v === 'closed' ? 'closed' : 'open')
}
>
<TabsList>
<TabsTrigger value="open">
<CircleDot className="size-3 text-green-500" /> Open
</TabsTrigger>
<TabsTrigger value="closed">
<CircleCheckBig className="size-3" /> Closed
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{folderParticle && (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<span className="flex size-7 items-center justify-center rounded-md bg-amber-500/15">
<FolderIcon className="size-4 text-amber-500" />
</span>
<h1 className="truncate text-sm font-semibold">
{folderParticle.properties.name}
</h1>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
<ParticleChildrenList
items={items}
networkId={networkId}
isLoading={isLoading}
onOpen={handleOpen}
selectedIndex={selectedIndex}
canLoadMore={canLoadMore}
onLoadMore={loadMore}
emptyMessage={
!isRoot
? 'This folder is empty. Add something using the keyboard shortcuts below.'
: statusTab === 'closed'
? 'No closed streams.'
: 'No streams here. Start a conversation using the keyboard shortcuts below.'
}
/>
</div>
<ComposeOverlay
networkId={networkId}
targetPath={isRoot ? undefined : path}
onActiveChange={setComposeActive}
/>
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<ContainerControls
isRoot={isRoot}
onCreateStream={() => setCreating('stream')}
/>
</div>
</div>
{creating && (
<ConfigureContainerStep
kind={creating}
networkId={networkId}
onCancel={() => setCreating(null)}
onSubmit={handleCreateContainer}
/>
)}
</div>
);
}
function ContainerControls({
isRoot,
onCreateStream,
}: {
isRoot: boolean;
onCreateStream: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Start recording (or hold `)"
>
to start
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Compose text (or press T)"
>
text
</KeyHint>
<KeyHint
keys="D"
onClick={() => requestIntent('task')}
title="Create a task (or press D)"
>
task
</KeyHint>
{!isRoot && (
<KeyHint
keys="N"
onClick={onCreateStream}
title="Create a stream here (or press N)"
>
stream
</KeyHint>
)}
</div>
);
}
@@ -2,21 +2,25 @@ import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import { softDeleteParticle } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from '@/lib/particle-path';
import type { Particle } from '@/api/types';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface DeleteParticleOverlayProps {
networkId: string;
streamId: string;
/** Path of the stream the particle lives in — may be nested. */
streamPath: ParticlePath;
particle: Particle;
userId: string;
onClose: () => void;
}
export function DeleteParticleOverlay({
networkId,
streamId,
streamPath,
particle,
userId,
onClose,
@@ -29,8 +33,9 @@ export function DeleteParticleOverlay({
if (deleting) return;
setDeleting(true);
try {
const { networkId, segments } = parseParticlePath(streamPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
particlePath(networkId, [...segments, particle.id]),
);
await softDeleteParticle(docPath, userId);
toast.success('Particle deleted');
@@ -41,7 +46,7 @@ export function DeleteParticleOverlay({
toast.error(message);
setDeleting(false);
}
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
}, [deleting, onClose, particle.id, streamPath, userId]);
return (
<ConfirmDestructiveOverlay
@@ -6,17 +6,11 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
FileIcon,
HelpCircleIcon,
ScrollTextIcon,
BookOpenIcon,
} from 'lucide-react';
import { FileIcon, HelpCircleIcon, BookOpenIcon } from 'lucide-react';
import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans';
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
quest: { icon: ScrollTextIcon, label: 'Quest' },
paper: { icon: BookOpenIcon, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
@@ -37,19 +31,19 @@ export function FallbackParticleView({
);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon,
label: particle.type,
label: particle.type === 'unknown' ? particle.raw_type : particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'folder':
return particle.properties.name;
case 'unknown':
return 'Not supported in this version of the app';
default:
return null;
}
@@ -1,21 +1,12 @@
import { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { ComposeOverlay } from '@/features/compose/compose-overlay';
import { type ParticlePath } from '@/lib/particle-path';
import { ContainerView } from '@/features/particles/container-view';
interface FolderViewProps {
folderParticle: Particle;
folderParticle: Particle & { type: 'folder' };
path: ParticlePath;
}
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { networkId } = parseParticlePath(path);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {folderParticle.id}
</p>
<ComposeOverlay networkId={networkId} />
</div>
);
return <ContainerView path={path} folderParticle={folderParticle} />;
}
@@ -1,25 +1,12 @@
import {
Fragment,
useMemo,
useRef,
useEffect,
useCallback,
memo,
createElement,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
Headphones,
Trash2,
type LucideIcon,
} from 'lucide-react';
import { Headphones, Radio, FolderIcon } from 'lucide-react';
import { cn, getInitials } from '@/lib/utils';
import { useLiveLatestChild } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
@@ -36,12 +23,17 @@ import {
type Particle,
type StreamProperties,
} from '@/api/types';
import type { StreamParticle } from '@/hooks/use-stream-particles';
import { useNetwork } from '@/hooks/use-networks';
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
import { useDownloadUrl } from '@/hooks/use-download-url';
import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display';
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
function VideoThumbnail({
objectId,
isUnseen,
@@ -71,65 +63,16 @@ function VideoThumbnail({
);
}
function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case 'text':
return MessageSquare;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith('image/')) return Image;
return Video;
}
case 'file':
return FileText;
case 'quest':
return CircleCheck;
case 'paper':
return StickyNote;
default:
return Radio;
}
}
function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) {
case 'text':
return particle.properties.content;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('image/')) return 'Photo';
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
}
return 'Media';
}
case 'file':
return particle.properties.filename;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
default:
return particle.type;
}
}
const StreamRow = memo(function StreamRow({
particle,
networkId,
onNavigate,
onOpen,
isSelected,
shortcutKey,
}: {
particle: Particle & { type: 'stream'; properties: StreamProperties };
particle: StreamParticle;
networkId: string;
onNavigate: (streamId: string) => void;
onOpen: (particleId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
@@ -237,9 +180,9 @@ const StreamRow = memo(function StreamRow({
<div
role="button"
tabIndex={0}
onClick={() => onNavigate(particle.id)}
onClick={() => onOpen(particle.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id);
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
@@ -322,34 +265,160 @@ const StreamRow = memo(function StreamRow({
);
});
interface ParticleListViewProps {
streams: StreamParticle[];
const FolderRow = memo(function FolderRow({
particle,
networkId,
onOpen,
isSelected,
shortcutKey,
}: {
particle: Particle & { type: 'folder' };
networkId: string;
onOpen: (particleId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
return (
<div
role="button"
tabIndex={0}
onClick={() => onOpen(particle.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
isSelected && 'bg-accent',
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-amber-500/15">
<FolderIcon className="size-4 text-amber-500" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{particle.properties.name}
</p>
<Small className="text-muted-foreground font-normal">
Folder · {creator.displayName}
</Small>
</div>
<Small className="shrink-0 text-muted-foreground">
<RelativeTimestamp date={particle.created_at} />
</Small>
</div>
);
});
const LeafRow = memo(function LeafRow({
particle,
networkId,
onOpen,
isSelected,
shortcutKey,
}: {
particle: Particle;
networkId: string;
onOpen: (particleId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
const typeIcon = getParticleTypeIcon(particle);
const deleted = isParticleDeleted(particle);
const taskDone = particle.type === 'task' && particle.properties.done;
return (
<div
role="button"
tabIndex={0}
onClick={() => onOpen(particle.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
isSelected && 'bg-accent',
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
{createElement(typeIcon, {
className: cn(
'size-4 shrink-0 text-muted-foreground',
taskDone && 'text-emerald-500',
),
})}
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate text-sm',
deleted || taskDone
? 'text-muted-foreground line-through'
: 'text-foreground',
)}
>
{getMessagePreview(particle)}
</p>
<Small className="text-muted-foreground font-normal">
{creator.displayName}
</Small>
</div>
<Small className="shrink-0 text-muted-foreground">
<RelativeTimestamp date={particle.created_at} />
</Small>
</div>
);
});
interface ParticleChildrenListProps {
items: Particle[];
networkId: string;
isLoading: boolean;
/** Open (navigate into / select) a particle by id. */
onOpen: (particleId: string) => void;
selectedIndex?: number | null;
/** Render 19 shortcut badges next to the first nine rows. */
showShortcuts?: boolean;
emptyMessage?: string;
/** When true, render a footer that invokes onLoadMore. */
canLoadMore?: boolean;
onLoadMore?: () => void;
}
/**
* List of stream particles for a container (network root, folder, etc.).
* Browsable list of a container's children, any particle type. Used by the
* network root and folder views.
*/
export function ParticleListView({
streams,
export function ParticleChildrenList({
items,
networkId,
isLoading,
onOpen,
selectedIndex,
showShortcuts = true,
emptyMessage = 'Nothing here yet. Create something using the keyboard shortcuts below.',
canLoadMore,
onLoadMore,
}: ParticleListViewProps) {
const navigate = useNavigate();
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const navigateToStream = useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
);
}: ParticleChildrenListProps) {
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
useEffect(() => {
if (
@@ -365,42 +434,52 @@ export function ParticleListView({
return <Progress />;
}
if (streams.length === 0) {
if (items.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No streams here. Start a conversation using the keyboard shortcuts
below.
</p>
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
</div>
);
}
return (
<div>
{streams.map((stream, index) => (
<StreamContextMenu
key={stream.id}
particle={stream}
networkId={networkId}
>
{items.map((item, index) => {
const rowProps = {
networkId,
onOpen,
isSelected: index === selectedIndex,
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
};
const row = (
<div
ref={(el) => {
rowRefs.current[index] = el;
}}
>
<StreamRow
particle={stream}
networkId={networkId}
onNavigate={navigateToStream}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
{item.type === 'stream' ? (
<StreamRow particle={item} {...rowProps} />
) : item.type === 'folder' ? (
<FolderRow particle={item} {...rowProps} />
) : (
<LeafRow particle={item} {...rowProps} />
)}
{index < items.length - 1 && <Separator className="px-4" />}
</div>
</StreamContextMenu>
))}
);
return item.type === 'stream' ? (
<StreamContextMenu
key={item.id}
particle={item}
networkId={networkId}
>
{row}
</StreamContextMenu>
) : (
<Fragment key={item.id}>{row}</Fragment>
);
})}
{canLoadMore && onLoadMore && (
<div className="flex justify-center p-3">
<Button variant="ghost" size="sm" onClick={onLoadMore}>
@@ -5,11 +5,12 @@ import { Skeleton } from '@/components/ui/skeleton';
import {
Video,
Mic,
ScrollText,
CircleCheck,
BookOpen,
FileIcon,
FolderIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
@@ -17,8 +18,8 @@ export function ParticlePreview({ particle }: { particle: Particle }) {
return <TextPreview particle={particle} />;
case 'media':
return <MediaPreview particle={particle} />;
case 'quest':
return <QuestPreview particle={particle} />;
case 'task':
return <TaskPreview particle={particle} />;
case 'paper':
return <PaperPreview particle={particle} />;
case 'file':
@@ -128,21 +129,30 @@ function VideoThumbnail({
);
}
function QuestPreview({
function TaskPreview({
particle,
}: {
particle: Extract<Particle, { type: 'quest' }>;
particle: Extract<Particle, { type: 'task' }>;
}) {
const { title, status } = particle.properties;
const { title, done } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
{status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{status}
</span>
)}
<CircleCheck
className={cn(
'h-6 w-6',
done
? 'text-emerald-600/80 dark:text-emerald-400/80'
: 'text-amber-600/70 dark:text-amber-400/70',
)}
/>
<p
className={cn(
'line-clamp-2 text-center text-sm font-medium',
done && 'text-muted-foreground line-through',
)}
>
{title}
</p>
</div>
);
}
@@ -2,12 +2,19 @@ import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Lock } from 'lucide-react';
import { isParticleDeleted, type Particle } from '@/api/types';
import { useLiveParticle } from '@/hooks/use-particle';
import { particlePath } from '@/lib/particle-path';
import { particlePath, type ParticlePath } from '@/lib/particle-path';
import { Button } from '@/components/ui/button';
import Layout from '@/features/layout';
import { StreamView } from '@/features/particles/stream-view';
import { FolderView } from '@/features/particles/folder-view';
import { MediaParticleView } from '@/features/particles/media-particle-view';
import { TextParticleView } from '@/features/particles/text-particle-view';
import { TaskParticleView } from '@/features/particles/task-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
/**
* Route-level component for /:networkId/*.
@@ -25,9 +32,11 @@ export default function ParticleViewResolver() {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
<Layout>
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
</Layout>
);
}
@@ -35,25 +44,106 @@ export default function ParticleViewResolver() {
// Errors here are almost always Firestore permission-denied — the user lost
// access to the network or to a custom-visibility particle. The React Router
// stays on the dead route, so without an explicit escape the user is stuck.
return <InaccessibleParticle />;
return (
<Layout>
<InaccessibleParticle />
</Layout>
);
}
// Streams render their own full-screen chrome; folders and leaves live
// inside the app Layout (breadcrumbs, full-height column) like the root.
switch (particle.type) {
case 'stream':
return <StreamView streamParticle={particle} path={path} />;
case 'folder':
return <FolderView folderParticle={particle} path={path} />;
return (
<Layout>
<FolderView folderParticle={particle} path={path} />
</Layout>
);
default:
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{particle.type} particle: {particle.id}
</p>
</div>
<Layout>
<LeafParticleView
particle={particle}
containerPath={particlePath(networkId, segments.slice(0, -1))}
networkId={networkId}
/>
</Layout>
);
}
}
const noop = () => {};
/**
* Standalone view for a leaf particle opened directly (e.g. from a folder),
* outside any stream playback: renders the particle's native view with no
* auto-advance.
*/
function LeafParticleView({
particle,
containerPath,
networkId,
}: {
particle: Particle;
containerPath: ParticlePath;
networkId: string;
}) {
const content = (() => {
if (isParticleDeleted(particle)) {
return (
<DeletedParticleView
particle={particle}
networkId={networkId}
paused
onEnded={noop}
/>
);
}
switch (particle.type) {
case 'media':
return (
<MediaParticleView
particle={particle}
streamPath={containerPath}
paused={false}
onEnded={noop}
/>
);
case 'text':
return (
<TextParticleView
particle={particle}
streamPath={containerPath}
paused
onEnded={noop}
/>
);
case 'task':
return (
<TaskParticleView
particle={particle}
containerPath={containerPath}
paused
onEnded={noop}
/>
);
default:
return (
<FallbackParticleView particle={particle} networkId={networkId} />
);
}
})();
return (
<div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
{content}
</div>
);
}
function InaccessibleParticle() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -4,18 +4,18 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import type { Particle } from '@/api/types';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface RenameStreamOverlayProps {
networkId: string;
streamPath: ParticlePath;
streamParticle: Particle & { type: 'stream' };
onClose: () => void;
}
export function RenameStreamOverlay({
networkId,
streamPath,
streamParticle,
onClose,
}: RenameStreamOverlayProps) {
@@ -32,15 +32,13 @@ export function RenameStreamOverlay({
if (!canSave) return;
setSaving(true);
try {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
const docPath = toFirestoreDocPath(streamPath);
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
onClose();
} finally {
setSaving(false);
}
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
}, [canSave, streamPath, onClose, trimmed]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -0,0 +1,141 @@
import { cn } from '@/lib/utils';
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import type { HumanPresence } from '@/hooks/use-presence-positions';
export function BottomBar({
visible,
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<number, HumanPresence[]>;
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
return (
<div
className={cn(
'absolute inset-x-0 bottom-0 z-10 transition-all duration-300',
visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2 pointer-events-none',
)}
>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/>
</div>
</div>
</div>
);
}
export function StreamViewControls({
showEscape,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<KeyHint
keys="Esc"
onClick={onExit}
title="Back to network (or press Esc)"
>
back
</KeyHint>
)}
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Reply with a recording (or hold `)"
>
to reply
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Reply with text (or press T)"
>
text
</KeyHint>
<KeyHint
keys="D"
onClick={() => requestIntent('task')}
title="Add a task (or press D)"
>
task
</KeyHint>
<KeyHint
keys="H"
onClick={onOpenHuddle}
title="Start a huddle (or press H)"
>
huddle
</KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div>
);
}
@@ -7,10 +7,10 @@ import {
import { CircleCheckBig, CircleDot } from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
import type { StreamParticle } from '@/hooks/use-stream-particles';
import { isStreamOpen, type Particle } from '@/api/types';
interface StreamContextMenuProps {
particle: StreamParticle;
particle: Extract<Particle, { type: 'stream' }>;
networkId: string;
children: React.ReactNode;
}
@@ -20,7 +20,7 @@ export function StreamContextMenu({
networkId,
children,
}: StreamContextMenuProps) {
const isOpen = particle.status === 'open';
const isOpen = isStreamOpen(particle);
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => {
@@ -0,0 +1,227 @@
import { useEffect, useRef } from 'react';
import { CircleCheck, FileText, Image, List, Mic, Video } from 'lucide-react';
import { isParticleDeleted, type Human, type Particle } from '@/api/types';
import { cn } from '@/lib/utils';
import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { HumanAvatar } from '@/components/human-avatar';
import { KeyHint } from '@/components/key-hint';
import { ScrollArea } from '@/components/ui/scroll-area';
interface StreamListSidebarProps {
streamName: string;
items: Particle[];
networkId: string;
currentIndex: number;
onSelect: (index: number) => void;
onToggle: () => void;
}
/**
* Browse-mode panel beside the stream: a chat-like timeline of every
* particle. Selecting a message plays it in the immersive stream view;
* nothing auto-advances.
*/
export function StreamListSidebar({
streamName,
items,
networkId,
currentIndex,
onSelect,
onToggle,
}: StreamListSidebarProps) {
const network = useNetwork(networkId);
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
useEffect(() => {
if (currentIndex >= 0) {
rowRefs.current[currentIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [currentIndex]);
return (
<aside className="dark flex w-96 shrink-0 flex-col border-l border-white/10 bg-zinc-950">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
<List className="size-3.5 text-white/40" />
<span className="truncate text-sm font-medium text-white/90">
{streamName}
</span>
<span className="ml-auto text-xs text-white/40">{items.length}</span>
<KeyHint
keys="L"
onClick={onToggle}
title="Hide list (or press L)"
className="text-xs text-white/40"
>
to close
</KeyHint>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 px-2 py-2">
{items.map((item, index) => (
<div
key={item.id}
ref={(el) => {
rowRefs.current[index] = el;
}}
>
<ChatRow
particle={item}
humans={network?.humans}
isSelected={index === currentIndex}
onClick={() => onSelect(index)}
/>
</div>
))}
{items.length === 0 && (
<p className="px-2 py-8 text-center text-sm text-white/40">
No particles in this stream yet.
</p>
)}
</div>
</ScrollArea>
</aside>
);
}
function ChatRow({
particle,
humans,
isSelected,
onClick,
}: {
particle: Particle;
humans: Human[] | undefined;
isSelected: boolean;
onClick: () => void;
}) {
const sender = resolveHumanDisplay(particle.created_by_human_id, humans);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
// Enter only — Space is reserved for play/pause in the stream view.
onKeyDown={(e) => {
if (e.key === 'Enter') onClick();
}}
coderabbitai[bot] commented 2026-06-12 18:14:18 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major | Quick win

Space-key row selection leaks into global playback hotkeys.

Line 107 handles Space on a div-button without preventing default/propagation. Combined with the global Space listener in use-playback-keys.ts (Line 56), keyboard row selection can also toggle playback pause.

🐛 Suggested fix
-    <div
-      role="button"
-      tabIndex={0}
-      onClick={onClick}
-      onKeyDown={(e) => {
-        if (e.key === 'Enter' || e.key === ' ') onClick();
-      }}
+    <button
+      type="button"
+      onClick={onClick}
       className={cn(
         'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
         isSelected ? 'bg-white/10' : 'hover:bg-white/5',
       )}
     >
@@
-    </div>
+    </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    <button
      type="button"
      onClick={onClick}
      className={cn(
        'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
        isSelected ? 'bg-white/10' : 'hover:bg-white/5',
      )}
    >
      {/* existing children content */}
    </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/features/particles/stream-list-sidebar.tsx` around lines 102 -
108, The Space-key handler on the clickable row (the div with role="button" and
onKeyDown) is not calling preventDefault/stopPropagation, which allows the
keypress to bubble to the global playback hotkeys in use-playback-keys.ts;
update the onKeyDown in stream-list-sidebar.tsx so that when handling ' '
(Space) (and optionally 'Enter') you call e.preventDefault() and
e.stopPropagation() before invoking onClick to stop the event from reaching the
global listener, keeping the existing onClick call and accessibility behavior
intact.
_⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_ **Space-key row selection leaks into global playback hotkeys.** Line 107 handles Space on a `div`-button without preventing default/propagation. Combined with the global Space listener in `use-playback-keys.ts` (Line 56), keyboard row selection can also toggle playback pause. <details> <summary>🐛 Suggested fix</summary> ```diff - <div - role="button" - tabIndex={0} - onClick={onClick} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onClick(); - }} + <button + type="button" + onClick={onClick} className={cn( 'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors', isSelected ? 'bg-white/10' : 'hover:bg-white/5', )} > @@ - </div> + </button> ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion <button type="button" onClick={onClick} className={cn( 'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors', isSelected ? 'bg-white/10' : 'hover:bg-white/5', )} > {/* existing children content */} </button> ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/features/particles/stream-list-sidebar.tsx` around lines 102 - 108, The Space-key handler on the clickable row (the div with role="button" and onKeyDown) is not calling preventDefault/stopPropagation, which allows the keypress to bubble to the global playback hotkeys in use-playback-keys.ts; update the onKeyDown in stream-list-sidebar.tsx so that when handling ' ' (Space) (and optionally 'Enter') you call e.preventDefault() and e.stopPropagation() before invoking onClick to stop the event from reaching the global listener, keeping the existing onClick call and accessibility behavior intact. ``` </details> <!-- fingerprinting:phantom:poseidon:hawk --> <!-- cr-comment:v1:9c48e34d6f3c96fbe1b1c15d --> <!-- This is an auto-generated comment by CodeRabbit -->
className={cn(
'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
)}
>
<HumanAvatar
size="sm"
className="mt-0.5 shrink-0"
initials={sender.initials}
avatarObjectId={sender.avatarObjectId}
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-xs font-semibold text-white/90">
{sender.displayName}
</span>
<span className="shrink-0 text-[10px] text-white/35">
<RelativeTimestamp date={particle.created_at} />
</span>
</div>
<ChatRowContent particle={particle} />
</div>
</div>
);
}
function ChatRowContent({ particle }: { particle: Particle }) {
if (isParticleDeleted(particle)) {
return (
<p className="text-xs italic text-white/35">This particle was deleted</p>
);
}
switch (particle.type) {
case 'text':
return (
<p className="line-clamp-3 text-xs leading-relaxed whitespace-pre-line text-white/70">
{particle.properties.content}
</p>
);
case 'media': {
const mime = particle.properties.mime_type;
const transcript = particle.properties.transcript?.transcript;
const isVideo = mime.startsWith('video/');
const isAudio = mime.startsWith('audio/');
const isImage = mime.startsWith('image/');
const Icon = isVideo ? Video : isAudio ? Mic : isImage ? Image : Video;
const label = isVideo
? 'Video clip'
: isAudio
? 'Voice note'
: isImage
? 'Photo'
: 'Media';
const durationSec = Math.round(particle.properties.duration_ms / 1000);
const duration =
durationSec > 0
? ` · ${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`
: '';
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<Icon className="size-3.5 shrink-0 text-white/50" />
{label}
{duration}
</span>
{transcript && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/45">
{transcript}
</p>
)}
</div>
);
}
case 'file':
return (
<span className="flex items-center gap-1.5 text-xs text-white/70">
<FileText className="size-3.5 shrink-0 text-white/50" />
<span className="truncate">{particle.properties.filename}</span>
</span>
);
case 'task': {
const { title, done, checklist = [] } = particle.properties;
const doneCount = checklist.filter((item) => item.done).length;
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<CircleCheck
className={cn(
'size-3.5 shrink-0',
done ? 'text-emerald-400' : 'text-white/50',
)}
/>
<span
className={cn('truncate', done && 'text-white/40 line-through')}
>
{title}
</span>
</span>
{checklist.length > 0 && (
<span className="pl-5 text-[10px] text-white/40">
{doneCount} / {checklist.length} subtasks
</span>
)}
</div>
);
}
case 'paper':
return (
<p className="truncate text-xs text-white/70">
{particle.properties.title}
</p>
);
default:
return <p className="text-xs text-white/45">{particle.type}</p>;
}
}
@@ -10,7 +10,7 @@ import {
parseVisibleTo,
} from '@/lib/stream-visibility';
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { useNetwork } from '@/hooks/use-networks';
import { cn, getInitials } from '@/lib/utils';
import { resolveHumanDisplay } from '@/lib/humans';
@@ -19,6 +19,7 @@ import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface StreamMembersOverlayProps {
networkId: string;
streamPath: ParticlePath;
streamParticle: Particle & { type: 'stream' };
isCreator: boolean;
onClose: () => void;
@@ -26,6 +27,7 @@ interface StreamMembersOverlayProps {
export function StreamMembersOverlay({
networkId,
streamPath,
streamParticle,
isCreator,
onClose,
@@ -40,10 +42,7 @@ export function StreamMembersOverlay({
[streamParticle.visible_to, networkId],
);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
[networkId, streamParticle.id],
);
const docPath = useMemo(() => toFirestoreDocPath(streamPath), [streamPath]);
const memberIds =
visibility.mode === 'network'
@@ -1,8 +1,7 @@
import { useState } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { isParticleDeleted, isStreamOpen, type Particle } from '@/api/types';
import { AvatarGroup } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import {
@@ -19,19 +18,21 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
CircleCheckBig,
CircleDot,
EllipsisVertical,
Pencil,
Lock,
Globe,
Trash2,
CircleCheckBig,
CircleDot,
} from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
import { parseVisibleTo } from '@/lib/stream-visibility';
import { getParticleDisplayName } from '@/lib/particle-display';
import {
Breadcrumb,
BreadcrumbItem,
@@ -46,31 +47,20 @@ import { resolveHumanDisplay } from '@/lib/humans';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
}
}
interface TopBarProps {
networkId: string;
particle: Particle | null;
streamParticle: Particle & { type: 'stream' };
/** Resolved path of the stream — may be nested under a container. */
streamPath: ParticlePath;
}
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
export function TopBar({
networkId,
particle,
streamParticle,
streamPath,
}: TopBarProps) {
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
@@ -161,8 +151,8 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</button>
)}
{streamParticle.status === 'closed' && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
{!isStreamOpen(streamParticle) && (
<span className="no-drag text-muted-foreground flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
1
@@ -185,18 +175,22 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
const docPath = toFirestoreDocPath(streamPath);
await updateStreamStatus(
docPath,
streamParticle.status === 'open' ? 'closed' : 'open',
isStreamOpen(streamParticle) ? 'closed' : 'open',
);
}}
>
{streamParticle.status === 'open' ? (
{isStreamOpen(streamParticle) ? (
<>
<CircleCheckBig className="size-4" />
Close stream
@@ -208,12 +202,6 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</>
)}
</DropdownMenuItem>
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
{canDeleteParticle && (
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
@@ -228,7 +216,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{renameOpen && isCreator && (
<RenameStreamOverlay
networkId={networkId}
streamPath={streamPath}
streamParticle={streamParticle}
onClose={() => setRenameOpen(false)}
/>
@@ -236,8 +224,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{deleteOpen && canDeleteParticle && particle && userId && (
<DeleteParticleOverlay
networkId={networkId}
streamId={streamParticle.id}
streamPath={streamPath}
particle={particle}
userId={userId}
onClose={() => setDeleteOpen(false)}
@@ -247,6 +234,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{membersOpen && (
<StreamMembersOverlay
networkId={networkId}
streamPath={streamPath}
streamParticle={streamParticle}
isCreator={isCreator}
onClose={() => setMembersOpen(false)}
+171 -241
View File
@@ -6,6 +6,7 @@ import {
useRef,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types';
@@ -19,17 +20,20 @@ import {
ComposeOverlay,
type ComposeStep,
} from '@/features/compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
import {
MediaParticleView,
type MediaParticleHandle,
} from '@/features/particles/media-particle-view';
import { TextParticleView } from '@/features/particles/text-particle-view';
import { TaskParticleView } from '@/features/particles/task-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import {
BottomBar,
StreamViewControls,
} from '@/features/particles/stream-bottom-bar';
import { StreamListSidebar } from '@/features/particles/stream-list-sidebar';
import { useStreamViewMode } from '@/hooks/use-stream-view-mode';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
import {
KeybindingsOverlay,
@@ -51,7 +55,6 @@ import {
type ComposingMode,
} from '@/features/particles/stream-presence-context';
import { ComposingIndicator } from '@/components/composing-indicator';
import { cn } from '@/lib/utils';
import { useMount } from 'react-use';
import {
usePlaybackPauseStore,
@@ -63,11 +66,29 @@ import { useStreamActionKeys } from '@/hooks/use-stream-action-keys';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
const noop = () => {};
// Compose step → the composing-presence mode broadcast to other viewers.
const STEP_TO_COMPOSING_MODE: Record<ComposeStep, ComposingMode | null> = {
idle: null,
submitting: null,
recording: 'recording',
typing: 'typing',
task: 'typing',
reviewing: 'typing',
configuring: 'typing',
picking: 'screen',
};
function getReactions(
particle: Particle,
): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
if (particle.type === 'media' || particle.type === 'text')
if (
particle.type === 'media' ||
particle.type === 'text' ||
particle.type === 'task'
)
return particle.reactions;
return undefined;
}
@@ -134,6 +155,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
bindings: [
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
{ keys: ['Esc'], description: 'Back to network' },
{ keys: ['L'], description: 'Toggle list view' },
],
},
{
@@ -151,6 +173,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{ keys: ['Hold', '`'], description: 'Reply' },
{ keys: ['S'], description: 'Screen record' },
{ keys: ['T'], description: 'Text compose' },
{ keys: ['D'], description: 'New task' },
{ keys: ['V'], description: 'Toggle video / audio' },
{ keys: ['H'], description: 'Join huddle' },
],
@@ -189,6 +212,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
platform.autoplay.dismiss();
});
const userId = useAuthStore((s) => s.user?.id);
const { mode, toggle: toggleViewMode } = useStreamViewMode(
streamParticle,
userId,
);
const {
children,
currentParticle,
@@ -198,7 +227,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
prev,
goTo,
goToParticle,
} = useStreamPlayback(streamParticle, path);
} = useStreamPlayback(streamParticle, path, {
autoAdvanceOnNew: mode === 'player',
});
usePrefetchAdjacentMedia(children, currentIndex);
@@ -258,7 +289,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
[handleToggleReaction],
);
const { fastPlayback } = usePlaybackKeys({ mediaRef });
const { fastPlayback, spacePaused, resume } = usePlaybackKeys({ mediaRef });
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
@@ -271,6 +302,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
childrenLength: children.length,
mediaRef,
onExit: handleExitNavigate,
onToggleViewMode: toggleViewMode,
});
const handleOpenHuddle = useCallback(() => {
@@ -301,16 +333,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Broadcast composing state to other viewers
useEffect(() => {
const stepToMode: Record<string, ComposingMode | null> = {
idle: null,
submitting: null,
recording: 'recording',
typing: 'typing',
reviewing: 'typing',
configuring: 'typing',
picking: 'screen',
};
const mode = stepToMode[composeStep] ?? null;
const mode = STEP_TO_COMPOSING_MODE[composeStep] ?? null;
if (mode) {
startComposing(mode);
} else {
@@ -331,7 +354,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === 'ended';
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
// No auto-exit while browsing in list mode.
const exitRemainingMs = useExitCountdown(
status,
paused || mode === 'list',
handleExitNavigate,
);
// Reset progress when the particle changes.
if (currentParticle?.id !== prevParticleId) {
@@ -371,14 +399,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated}
/>
</div>
);
}
// Render particle content inline
// Render particle content inline. In list mode the selected particle still
// plays, but nothing chains: reaching the end doesn't advance.
const handleParticleEnded = mode === 'player' ? next : noop;
function renderParticle(particle: Particle) {
if (isParticleDeleted(particle)) {
return (
@@ -387,7 +416,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
particle={particle}
networkId={networkId}
paused={paused}
onEnded={next}
onEnded={handleParticleEnded}
/>
);
}
@@ -400,7 +429,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
@@ -411,7 +440,18 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
case 'task':
return (
<TaskParticleView
key={particle.id}
particle={particle}
containerPath={path}
paused={paused}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
@@ -423,233 +463,123 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
}
return (
<div
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
<div className="flex h-screen overflow-hidden bg-black">
{/* Stream chrome — immersive playback column */}
<div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar
networkId={networkId}
particle={currentParticle}
streamParticle={streamParticle}
/>
</div>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{paused && (
<div className="rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm">
Paused
</div>
)}
</div>
</div>
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ''}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar
networkId={networkId}
particle={currentParticle}
streamParticle={streamParticle}
streamPath={path}
/>
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
);
}
function BottomBar({
visible,
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<
number,
import('@/hooks/use-presence-positions').HumanPresence[]
>;
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
return (
<div
className={cn(
'absolute inset-x-0 bottom-0 z-10 transition-all duration-300',
visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2 pointer-events-none',
)}
>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{spacePaused && (
<button
type="button"
onClick={resume}
title="Resume (or press Space)"
className="pointer-events-auto flex items-center gap-1.5 rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
>
<Play className="size-3 fill-current" />
Paused
</button>
)}
</div>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/>
</div>
</div>
</div>
);
}
function StreamViewControls({
showEscape,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<KeyHint
keys="Esc"
onClick={onExit}
title="Back to network (or press Esc)"
>
back
</KeyHint>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ''}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
/>
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
onParticleCreated={handleParticleCreated}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
<BottomBar
visible={mode === 'list' || controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
{/* Browse sidebar — a separate chat-like panel beside the stream */}
{mode === 'list' && (
<StreamListSidebar
streamName={streamParticle.properties.name}
items={children}
networkId={networkId}
currentIndex={currentIndex}
onSelect={goTo}
onToggle={toggleViewMode}
/>
)}
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Reply with a recording (or hold `)"
>
to reply
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Reply with text (or press T)"
>
text
</KeyHint>
<KeyHint
keys="H"
onClick={onOpenHuddle}
title="Start a huddle (or press H)"
>
huddle
</KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div>
);
}
@@ -0,0 +1,335 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { deleteField } from 'firebase/firestore';
import { Plus, X } from 'lucide-react';
import type { ChecklistItem, Particle } from '@/api/types';
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from '@/lib/particle-path';
import {
updateParticle,
updateParticleProperties,
} from '@/lib/firestore-particles';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { HumanAvatar } from '@/components/human-avatar';
import { useNetwork } from '@/hooks/use-networks';
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';
type TaskParticle = Extract<Particle, { type: 'task' }>;
interface TaskParticleViewProps {
particle: TaskParticle;
containerPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const DWELL_DURATION_S = 8;
const UNASSIGNED = 'unassigned';
function useParticleDocPath(
containerPath: ParticlePath,
particleId: string,
): string {
const { networkId, segments } = parseParticlePath(containerPath);
return toFirestoreDocPath(particlePath(networkId, [...segments, particleId]));
}
export function TaskParticleView({
particle,
containerPath,
paused,
onEnded,
onProgress,
}: TaskParticleViewProps) {
const { networkId } = parseParticlePath(containerPath);
const network = useNetwork(networkId);
const docPath = useParticleDocPath(containerPath, particle.id);
const {
title,
notes,
checklist = [],
assigned_to,
done,
} = particle.properties;
// Suspend playback while any field inside the card has focus so typing
// doesn't race the dwell timer or get eaten by global key handlers.
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`);
useFixedDwell({
id: particle.id,
durationS: DWELL_DURATION_S,
paused,
onEnded,
onProgress,
});
const titleField = useLiveDraftField({
remoteValue: title,
commit: (value) =>
updateParticleProperties<'task'>(docPath, { title: value }),
});
const notesField = useLiveDraftField({
remoteValue: notes ?? '',
commit: (value) =>
updateParticleProperties<'task'>(docPath, { notes: value }),
});
// Checklist writes replace the whole array (merged against the latest live
// value); concurrent edits to the same checklist are last-write-wins.
const checklistRef = useRef(checklist);
useEffect(() => {
checklistRef.current = checklist;
}, [checklist]);
const writeChecklist = useCallback(
(items: ChecklistItem[]) => {
// Advance the local base before the write so a second edit issued before
// the next snapshot composes on top of this one instead of dropping it.
checklistRef.current = items;
return updateParticleProperties<'task'>(docPath, { checklist: items });
},
[docPath],
);
const handleToggleDone = useCallback(
(checked: boolean) =>
updateParticleProperties<'task'>(docPath, { done: checked }),
[docPath],
);
const handleToggleItem = useCallback(
(index: number, checked: boolean) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, done: checked } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleCommitItemText = useCallback(
(index: number, text: string) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, text } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleRemoveItem = useCallback(
(index: number) => {
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
},
[writeChecklist],
);
const handleAddItem = useCallback(
(text: string) => {
void writeChecklist([...checklistRef.current, { text, done: false }]);
},
[writeChecklist],
);
coderabbitai[bot] commented 2026-06-12 18:14:18 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major | Quick win

Make writeChecklist advance the local base state before the Firestore round-trip.

All four checklist handlers branch from checklistRef.current, but that ref is only refreshed by the next snapshot in Lines 99-101. Because updateParticleProperties() writes the entire properties.checklist field, two quick local edits under normal latency will both derive from the same stale array and the later write can drop the earlier change. Update the ref before issuing the write, or keep an optimistic local checklist state, so consecutive edits compose locally.

Suggested fix
  const writeChecklist = useCallback(
-    (items: ChecklistItem[]) =>
-      updateParticleProperties<'task'>(docPath, { checklist: items }),
+    (items: ChecklistItem[]) => {
+      checklistRef.current = items;
+      return updateParticleProperties<'task'>(docPath, { checklist: items });
+    },
    [docPath],
  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  const writeChecklist = useCallback(
    (items: ChecklistItem[]) => {
      checklistRef.current = items;
      return updateParticleProperties<'task'>(docPath, { checklist: items });
    },
    [docPath],
  );

  const handleToggleDone = useCallback(
    (checked: boolean) =>
      updateParticleProperties<'task'>(docPath, { done: checked }),
    [docPath],
  );

  const handleToggleItem = useCallback(
    (index: number, checked: boolean) => {
      const items = checklistRef.current.map((item, i) =>
        i === index ? { ...item, done: checked } : item,
      );
      void writeChecklist(items);
    },
    [writeChecklist],
  );

  const handleCommitItemText = useCallback(
    (index: number, text: string) => {
      const items = checklistRef.current.map((item, i) =>
        i === index ? { ...item, text } : item,
      );
      void writeChecklist(items);
    },
    [writeChecklist],
  );

  const handleRemoveItem = useCallback(
    (index: number) => {
      void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
    },
    [writeChecklist],
  );

  const handleAddItem = useCallback(
    (text: string) => {
      void writeChecklist([...checklistRef.current, { text, done: false }]);
    },
    [writeChecklist],
  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/features/particles/task-particle-view.tsx` around lines 103 -
147, The handlers (handleToggleItem, handleCommitItemText, handleRemoveItem,
handleAddItem) read checklistRef.current and then call writeChecklist which
races with Firestore updates, so update the local base before the round-trip:
modify writeChecklist (or each handler) to first advance checklistRef.current to
the new items (e.g., checklistRef.current = items / update the optimistic local
checklist state) and then call updateParticleProperties<'task'>(docPath, {
checklist: items }); ensure checklistRef is kept in sync so consecutive local
edits compose correctly.

Addressed in commit 044f1a7

_⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_ **Make `writeChecklist` advance the local base state before the Firestore round-trip.** All four checklist handlers branch from `checklistRef.current`, but that ref is only refreshed by the next snapshot in Lines 99-101. Because `updateParticleProperties()` writes the entire `properties.checklist` field, two quick local edits under normal latency will both derive from the same stale array and the later write can drop the earlier change. Update the ref before issuing the write, or keep an optimistic local checklist state, so consecutive edits compose locally. <details> <summary>Suggested fix</summary> ```diff const writeChecklist = useCallback( - (items: ChecklistItem[]) => - updateParticleProperties<'task'>(docPath, { checklist: items }), + (items: ChecklistItem[]) => { + checklistRef.current = items; + return updateParticleProperties<'task'>(docPath, { checklist: items }); + }, [docPath], ); ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion const writeChecklist = useCallback( (items: ChecklistItem[]) => { checklistRef.current = items; return updateParticleProperties<'task'>(docPath, { checklist: items }); }, [docPath], ); const handleToggleDone = useCallback( (checked: boolean) => updateParticleProperties<'task'>(docPath, { done: checked }), [docPath], ); const handleToggleItem = useCallback( (index: number, checked: boolean) => { const items = checklistRef.current.map((item, i) => i === index ? { ...item, done: checked } : item, ); void writeChecklist(items); }, [writeChecklist], ); const handleCommitItemText = useCallback( (index: number, text: string) => { const items = checklistRef.current.map((item, i) => i === index ? { ...item, text } : item, ); void writeChecklist(items); }, [writeChecklist], ); const handleRemoveItem = useCallback( (index: number) => { void writeChecklist(checklistRef.current.filter((_, i) => i !== index)); }, [writeChecklist], ); const handleAddItem = useCallback( (text: string) => { void writeChecklist([...checklistRef.current, { text, done: false }]); }, [writeChecklist], ); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/features/particles/task-particle-view.tsx` around lines 103 - 147, The handlers (handleToggleItem, handleCommitItemText, handleRemoveItem, handleAddItem) read checklistRef.current and then call writeChecklist which races with Firestore updates, so update the local base before the round-trip: modify writeChecklist (or each handler) to first advance checklistRef.current to the new items (e.g., checklistRef.current = items / update the optimistic local checklist state) and then call updateParticleProperties<'task'>(docPath, { checklist: items }); ensure checklistRef is kept in sync so consecutive local edits compose correctly. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- cr-comment:v1:9e22918085088dd0eff33910 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit 044f1a7
const handleAssign = useCallback(
(value: string) => {
if (value === UNASSIGNED) {
void updateParticle(docPath, 'properties.assigned_to', deleteField());
} else {
void updateParticleProperties<'task'>(docPath, { assigned_to: value });
}
},
[docPath],
);
const assignee = resolveHumanDisplay(assigned_to, network?.humans);
const doneCount = checklist.filter((item) => item.done).length;
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)]">
<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"
onFocusCapture={() => setEditing(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
}}
coderabbitai[bot] commented 2026-06-12 18:14:18 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant sections of the file under review
sed -n '130,270p' js/desktop/src/features/particles/task-particle-view.tsx

# Locate and inspect the Select component used for portals/open state handling
sed -n '1,240p' js/desktop/src/components/ui/select.tsx

# Also search for the useSuspendPlayback hook signature/usages (to confirm what "editing" should represent)
rg -n "useSuspendPlayback" -S js/desktop/src | head -n 50
rg -n "task-edit-" -S js/desktop/src | head -n 50

Repository: flowy-live/llink

Length of output: 14569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# task-particle-view: inspect state/hooks around editing + useSuspendPlayback
sed -n '1,120p' js/desktop/src/features/particles/task-particle-view.tsx
sed -n '120,210p' js/desktop/src/features/particles/task-particle-view.tsx

# useSuspendPlayback hook implementation
sed -n '1,200p' js/desktop/src/hooks/use-suspend-playback.ts

# Look for other patterns of tracking open state for “open portals”
sed -n '1,120p' js/desktop/src/features/particles/text-reaction-input.tsx
sed -n '1,140p' js/desktop/src/features/attachments/attachment-lightbox.tsx

Repository: flowy-live/llink

Length of output: 14935


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# inspect playback-pause-store to see what happens when suspended ids are active
sed -n '1,220p' js/desktop/src/stores/playback-pause-store.ts

# inspect where suspension affects playback / global key handlers
rg -n "playback-pause-store|usePlaybackPauseStore" js/desktop/src | head -n 50
rg -n "isPaused|paused.*playback|pause.*playback|playbackPause" js/desktop/src | head -n 50

# inspect task-particle-view around editing + blur handling lines
nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,210p'

# inspect for Radix Select open usage elsewhere in repo (patterns similar to suggested fix)
rg -n "onOpenChange={.*set.*Open|onOpenChange" js/desktop/src/features js/desktop/src/components | head -n 50
rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 50

Repository: flowy-live/llink

Length of output: 2310


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,200p' js/desktop/src/stores/playback-pause-store.ts
rg -n "usePlaybackPauseStore" js/desktop/src | head -n 100

nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '1,140p'
nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,240p'

rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 200
rg -n "onOpenChange" js/desktop/src -S | head -n 200

Repository: flowy-live/llink

Length of output: 1915


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,200p' js/desktop/src/stores/playback-pause-store.ts
rg -n "usePlaybackPauseStore" js/desktop/src | head -n 100

nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '1,140p'
nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,240p'

rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 200
rg -n "onOpenChange" js/desktop/src -S | head -n 200

Repository: flowy-live/llink

Length of output: 1915


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,200p' js/desktop/src/stores/playback-pause-store.ts
rg -n "usePlaybackPauseStore" js/desktop/src | head -n 100

nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '1,140p'
nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,240p'

rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 200
rg -n "onOpenChange" js/desktop/src -S | head -n 200

Repository: flowy-live/llink

Length of output: 1915


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# task-particle-view: show the exact relevant sections with line numbers
cat -n js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,210p'
cat -n js/desktop/src/features/particles/task-particle-view.tsx | sed -n '210,270p'

# playback suspension effects: where paused state is checked in key handlers
sed -n '1,220p' js/desktop/src/hooks/use-stream-action-keys.ts
sed -n '1,220p' js/desktop/src/hooks/use-playback-keys.ts
sed -n '240,360p' js/desktop/src/features/particles/stream-view.tsx

Repository: flowy-live/llink

Length of output: 14647


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "\\bpaused\\b" js/desktop/src/features/particles/stream-view.tsx | head -n 80
rg -n "useExitCountdown|useFixedDwell|dwell|playback" js/desktop/src/features/particles/stream-view.tsx | head -n 120

Repository: flowy-live/llink

Length of output: 745


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# show editing + suspender wiring in task-particle-view
cat -n js/desktop/src/features/particles/task-particle-view.tsx | sed -n '50,110p'

# inspect use-fixed-dwell to see how its `paused` prop affects dwell/playback behavior
sed -n '1,220p' js/desktop/src/hooks/use-fixed-dwell.ts

# confirm select content uses Radix portal + wrapper forwards Root props (for onOpenChange)
rg -n "SelectPrimitive\\.Portal|function Select\\(" js/desktop/src/components/ui/select.tsx

Repository: flowy-live/llink

Length of output: 3413


🌐 Web query:

Radix UI Select onOpenChange focus moved to content portal keyboard focus behavior

💡 Result:

In Radix UI Select, focus behavior when the dropdown opens is mainly controlled by the Select implementation (including its portalized content) rather than directly by onOpenChange. When Select.Content is rendered in a portal, focus “within” semantics and some focus-related heuristics can differ from non-portal DOM placement, which leads to quirks like “parent loses focus-within” and, in some container setups (e.g., Dialog/Drawer), missing or inconsistent focus on the first item. 1) onOpenChange vs focus: what actually moves focus - onOpenChange is just the controlled open state callback; Select’s internal focus management happens at open/close time in the Select component code. - Radix’s own Select docs describe keyboard behavior when opening/closing, including that focus is moved to items and returned to the trigger (e.g., Space/Enter opens and focuses the selected/first item; Esc closes and moves focus back to the trigger) [1]. - The Select docs also show that the content “pops out” and is rendered into a portal (defaulting to document.body) [1][2]. 2) Why portalization changes focus interactions (the core issue behind the reported behavior) - A known quirk is that ancestors can lose :focus-within when Select is opened because Select.Content is rendered in a portal (so the content is not within the ancestor’s DOM tree) [3]. This is consistent with browser semantics: :focus-within depends on DOM ancestry, not just global focus state. 3) Real-world symptoms with focus not landing on the first item inside overlay containers - There are multiple Radix issues where Select used inside higher-level primitives (Dialog/Drawer) does not focus items as expected on open. - Example: when Select is nested within a Dialog, the first SelectItem may not be automatically focused, breaking typeahead/arrow navigation [4]. - Example: inside a Drawer, only the trigger is focusable; opening the Select content does not move focus to the items, so arrow-key navigation doesn’t work [5]. - These reports strongly indicate that “content portal keyboard focus behavior” can be affected by the parent container’s focus management (focus trap, focus restoration, overlay behaviors), even though Select itself intends to focus the appropriate item when opened [1][5]. 4) What this means for your specific query (“focus moved to content portal keyboard focus behavior”) - If by “focus moved to content portal” you mean: “when Select.Content is portal-rendered, focus/keyboard interaction does/doesn’t go into the portal content,” then the evidence shows: - Select.Content is portal-rendered by default [1][2]. - Because it’s portal-rendered, DOM-based focus rules like :focus-within for ancestors can change [3]. - Additionally, in certain overlay contexts (Dialog/Drawer), Select may fail to focus the first item on open, which is observable as broken keyboard navigation [4][5]. If you tell me which Radix version(s) you’re using and what container you render Select inside (Dialog, Drawer, custom portal, etc.), I can narrow down which focus path is failing and what configuration/workaround to apply (e.g., focusing the trigger vs. ensuring focus trapping doesn’t swallow the initial item focus).

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "<Select\\b" js/desktop/src | head -n 80
rg -n "onOpenChange\\s*=\\s*\\{\\s*set.*Open|onOpenChange\\s*=\\s*\\w+Set|onOpenChange\\s*=\\s*set" js/desktop/src/components js/desktop/src/features -S | head -n 80
rg -n "SelectPrimitive\\.Root" js/desktop/src/components/ui/select.tsx

Repository: flowy-live/llink

Length of output: 1055


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "onBlurCapture=\\{\\(e\\) => \\{\\s*if \\(!e\\.currentTarget\\.contains\\(e\\.relatedTarget\\)\\) setEditing\\(false\\);\\s*\\}\\}" -S js/desktop/src || true
rg -n "onBlurCapture=.*contains\\(e\\.relatedTarget\\)" -S js/desktop/src | head -n 50
rg -n "useSuspendPlayback\\(.*Select|assigneeMenuOpen|onOpenChange=.*useSuspendPlayback" -S js/desktop/src || true

Repository: flowy-live/llink

Length of output: 42


🏁 Script executed:

rg -n "onBlurCapture" js/desktop/src | head -n 80
rg -n "relatedTarget" js/desktop/src | head -n 80

Repository: flowy-live/llink

Length of output: 291


Suspend playback while the assignee Select menu is open

task-particle-view.tsx flips editing to false on card blur using a DOM containment check; meanwhile the assignee <Select> menu is portal-rendered (SelectPrimitive.Portal), so focus can move outside the card while the dropdown is open—un-suspending playback and allowing the dwell timer to resume during menu interaction. Track the Select’s open state via onOpenChange and include it in the suspender condition.

File: js/desktop/src/features/particles/task-particle-view.tsx
Lines: 167-170

Snippet showing the final state of code at these lines

The below code does not show removed or replaced code.

        onFocusCapture={() => setEditing(true)}
        onBlurCapture={(e) => {
          if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
        }}

Comment

Suggested fix
-  const [editing, setEditing] = useState(false);
-  useSuspendPlayback(editing, `task-edit-${particle.id}`);
+  const [editing, setEditing] = useState(false);
+  const [assigneeMenuOpen, setAssigneeMenuOpen] = useState(false);
+  useSuspendPlayback(
+    editing || assigneeMenuOpen,
+    `task-edit-${particle.id}`,
+  );
-          <Select
+          <Select
+            onOpenChange={setAssigneeMenuOpen}
             value={assigned_to ?? UNASSIGNED}
             onValueChange={handleAssign}
           >

Also applies to: 222-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/features/particles/task-particle-view.tsx` around lines 167 -
170, The blur handler flips editing off even when the portal-rendered assignee
Select is open, so add local state (e.g. isAssigneeOpen) and wire the assignee
Select's onOpenChange to set that state, then update the focus/blur suspension
logic to also check isAssigneeOpen before calling setEditing(false) (i.e. in
onBlurCapture leave editing true if isAssigneeOpen is true); apply the same
change to the other similar block that uses setEditing and onBlurCapture so the
dropdown interaction doesn't unsuspend playback.
_⚠️ Potential issue_ | _🟠 Major_ <details> <summary>🧩 Analysis chain</summary> 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail # Show relevant sections of the file under review sed -n '130,270p' js/desktop/src/features/particles/task-particle-view.tsx # Locate and inspect the Select component used for portals/open state handling sed -n '1,240p' js/desktop/src/components/ui/select.tsx # Also search for the useSuspendPlayback hook signature/usages (to confirm what "editing" should represent) rg -n "useSuspendPlayback" -S js/desktop/src | head -n 50 rg -n "task-edit-" -S js/desktop/src | head -n 50 ``` Repository: flowy-live/llink Length of output: 14569 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail # task-particle-view: inspect state/hooks around editing + useSuspendPlayback sed -n '1,120p' js/desktop/src/features/particles/task-particle-view.tsx sed -n '120,210p' js/desktop/src/features/particles/task-particle-view.tsx # useSuspendPlayback hook implementation sed -n '1,200p' js/desktop/src/hooks/use-suspend-playback.ts # Look for other patterns of tracking open state for “open portals” sed -n '1,120p' js/desktop/src/features/particles/text-reaction-input.tsx sed -n '1,140p' js/desktop/src/features/attachments/attachment-lightbox.tsx ``` Repository: flowy-live/llink Length of output: 14935 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail # inspect playback-pause-store to see what happens when suspended ids are active sed -n '1,220p' js/desktop/src/stores/playback-pause-store.ts # inspect where suspension affects playback / global key handlers rg -n "playback-pause-store|usePlaybackPauseStore" js/desktop/src | head -n 50 rg -n "isPaused|paused.*playback|pause.*playback|playbackPause" js/desktop/src | head -n 50 # inspect task-particle-view around editing + blur handling lines nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,210p' # inspect for Radix Select open usage elsewhere in repo (patterns similar to suggested fix) rg -n "onOpenChange={.*set.*Open|onOpenChange" js/desktop/src/features js/desktop/src/components | head -n 50 rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 50 ``` Repository: flowy-live/llink Length of output: 2310 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail sed -n '1,200p' js/desktop/src/stores/playback-pause-store.ts rg -n "usePlaybackPauseStore" js/desktop/src | head -n 100 nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '1,140p' nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,240p' rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 200 rg -n "onOpenChange" js/desktop/src -S | head -n 200 ``` Repository: flowy-live/llink Length of output: 1915 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail sed -n '1,200p' js/desktop/src/stores/playback-pause-store.ts rg -n "usePlaybackPauseStore" js/desktop/src | head -n 100 nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '1,140p' nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,240p' rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 200 rg -n "onOpenChange" js/desktop/src -S | head -n 200 ``` Repository: flowy-live/llink Length of output: 1915 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail sed -n '1,200p' js/desktop/src/stores/playback-pause-store.ts rg -n "usePlaybackPauseStore" js/desktop/src | head -n 100 nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '1,140p' nl -ba js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,240p' rg -n "<Select\\b" js/desktop/src/features js/desktop/src/components -S | head -n 200 rg -n "onOpenChange" js/desktop/src -S | head -n 200 ``` Repository: flowy-live/llink Length of output: 1915 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail # task-particle-view: show the exact relevant sections with line numbers cat -n js/desktop/src/features/particles/task-particle-view.tsx | sed -n '140,210p' cat -n js/desktop/src/features/particles/task-particle-view.tsx | sed -n '210,270p' # playback suspension effects: where paused state is checked in key handlers sed -n '1,220p' js/desktop/src/hooks/use-stream-action-keys.ts sed -n '1,220p' js/desktop/src/hooks/use-playback-keys.ts sed -n '240,360p' js/desktop/src/features/particles/stream-view.tsx ``` Repository: flowy-live/llink Length of output: 14647 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail rg -n "\\bpaused\\b" js/desktop/src/features/particles/stream-view.tsx | head -n 80 rg -n "useExitCountdown|useFixedDwell|dwell|playback" js/desktop/src/features/particles/stream-view.tsx | head -n 120 ``` Repository: flowy-live/llink Length of output: 745 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail # show editing + suspender wiring in task-particle-view cat -n js/desktop/src/features/particles/task-particle-view.tsx | sed -n '50,110p' # inspect use-fixed-dwell to see how its `paused` prop affects dwell/playback behavior sed -n '1,220p' js/desktop/src/hooks/use-fixed-dwell.ts # confirm select content uses Radix portal + wrapper forwards Root props (for onOpenChange) rg -n "SelectPrimitive\\.Portal|function Select\\(" js/desktop/src/components/ui/select.tsx ``` Repository: flowy-live/llink Length of output: 3413 --- 🌐 Web query: `Radix UI Select onOpenChange focus moved to content portal keyboard focus behavior` 💡 Result: In Radix UI Select, focus behavior when the dropdown opens is mainly controlled by the Select implementation (including its portalized content) rather than directly by `onOpenChange`. When `Select.Content` is rendered in a portal, focus “within” semantics and some focus-related heuristics can differ from non-portal DOM placement, which leads to quirks like “parent loses focus-within” and, in some container setups (e.g., Dialog/Drawer), missing or inconsistent focus on the first item. 1) `onOpenChange` vs focus: what actually moves focus - `onOpenChange` is just the controlled open state callback; Select’s internal focus management happens at open/close time in the Select component code. - Radix’s own Select docs describe keyboard behavior when opening/closing, including that focus is moved to items and returned to the trigger (e.g., Space/Enter opens and focuses the selected/first item; Esc closes and moves focus back to the trigger) [1]. - The Select docs also show that the content “pops out” and is rendered into a portal (defaulting to `document.body`) [1][2]. 2) Why portalization changes focus interactions (the core issue behind the reported behavior) - A known quirk is that ancestors can lose `:focus-within` when Select is opened because `Select.Content` is rendered in a portal (so the content is not within the ancestor’s DOM tree) [3]. This is consistent with browser semantics: `:focus-within` depends on DOM ancestry, not just global focus state. 3) Real-world symptoms with focus not landing on the first item inside overlay containers - There are multiple Radix issues where Select used inside higher-level primitives (Dialog/Drawer) does not focus items as expected on open. - Example: when Select is nested within a Dialog, the first `SelectItem` may not be automatically focused, breaking typeahead/arrow navigation [4]. - Example: inside a Drawer, only the trigger is focusable; opening the Select content does not move focus to the items, so arrow-key navigation doesn’t work [5]. - These reports strongly indicate that “content portal keyboard focus behavior” can be affected by the parent container’s focus management (focus trap, focus restoration, overlay behaviors), even though Select itself intends to focus the appropriate item when opened [1][5]. 4) What this means for your specific query (“focus moved to content portal keyboard focus behavior”) - If by “focus moved to content portal” you mean: “when `Select.Content` is portal-rendered, focus/keyboard interaction does/doesn’t go into the portal content,” then the evidence shows: - Select.Content is portal-rendered by default [1][2]. - Because it’s portal-rendered, DOM-based focus rules like `:focus-within` for ancestors can change [3]. - Additionally, in certain overlay contexts (Dialog/Drawer), Select may fail to focus the first item on open, which is observable as broken keyboard navigation [4][5]. If you tell me which Radix version(s) you’re using and what container you render Select inside (Dialog, Drawer, custom portal, etc.), I can narrow down which focus path is failing and what configuration/workaround to apply (e.g., focusing the trigger vs. ensuring focus trapping doesn’t swallow the initial item focus). Citations: - 1: https://radix-ui-primitives.mintlify.app/components/select - 2: https://www.radix-ui.com/primitives/docs/components/select.md - 3: https://github.com/radix-ui/primitives/issues/1291 - 4: https://github.com/radix-ui/primitives/issues/3306 - 5: https://github.com/radix-ui/primitives/issues/3520 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail rg -n "<Select\\b" js/desktop/src | head -n 80 rg -n "onOpenChange\\s*=\\s*\\{\\s*set.*Open|onOpenChange\\s*=\\s*\\w+Set|onOpenChange\\s*=\\s*set" js/desktop/src/components js/desktop/src/features -S | head -n 80 rg -n "SelectPrimitive\\.Root" js/desktop/src/components/ui/select.tsx ``` Repository: flowy-live/llink Length of output: 1055 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail rg -n "onBlurCapture=\\{\\(e\\) => \\{\\s*if \\(!e\\.currentTarget\\.contains\\(e\\.relatedTarget\\)\\) setEditing\\(false\\);\\s*\\}\\}" -S js/desktop/src || true rg -n "onBlurCapture=.*contains\\(e\\.relatedTarget\\)" -S js/desktop/src | head -n 50 rg -n "useSuspendPlayback\\(.*Select|assigneeMenuOpen|onOpenChange=.*useSuspendPlayback" -S js/desktop/src || true ``` Repository: flowy-live/llink Length of output: 42 --- 🏁 Script executed: ```shell rg -n "onBlurCapture" js/desktop/src | head -n 80 rg -n "relatedTarget" js/desktop/src | head -n 80 ``` Repository: flowy-live/llink Length of output: 291 --- </details> **Suspend playback while the assignee Select menu is open** `task-particle-view.tsx` flips `editing` to `false` on card blur using a DOM containment check; meanwhile the assignee `<Select>` menu is portal-rendered (`SelectPrimitive.Portal`), so focus can move outside the card while the dropdown is open—un-suspending playback and allowing the dwell timer to resume during menu interaction. Track the Select’s open state via `onOpenChange` and include it in the suspender condition. File: js/desktop/src/features/particles/task-particle-view.tsx Lines: 167-170 ## Snippet showing the final state of code at these lines The below code does not show removed or replaced code. ``` onFocusCapture={() => setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); }} ``` ## Comment <details> <summary>Suggested fix</summary> ```diff - const [editing, setEditing] = useState(false); - useSuspendPlayback(editing, `task-edit-${particle.id}`); + const [editing, setEditing] = useState(false); + const [assigneeMenuOpen, setAssigneeMenuOpen] = useState(false); + useSuspendPlayback( + editing || assigneeMenuOpen, + `task-edit-${particle.id}`, + ); … - <Select + <Select + onOpenChange={setAssigneeMenuOpen} value={assigned_to ?? UNASSIGNED} onValueChange={handleAssign} > ``` </details> Also applies to: 222-247 <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/features/particles/task-particle-view.tsx` around lines 167 - 170, The blur handler flips editing off even when the portal-rendered assignee Select is open, so add local state (e.g. isAssigneeOpen) and wire the assignee Select's onOpenChange to set that state, then update the focus/blur suspension logic to also check isAssigneeOpen before calling setEditing(false) (i.e. in onBlurCapture leave editing true if isAssigneeOpen is true); apply the same change to the other similar block that uses setEditing and onBlurCapture so the dropdown interaction doesn't unsuspend playback. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- cr-comment:v1:a2a54d5efe49a3e931d165d6 --> <!-- This is an auto-generated comment by CodeRabbit -->
>
<div className="flex items-start gap-3">
<Checkbox
checked={done}
onCheckedChange={(checked) => handleToggleDone(checked === true)}
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
/>
<input
value={titleField.value}
onChange={(e) => titleField.onChange(e.target.value)}
onFocus={titleField.onFocus}
onBlur={titleField.onBlur}
placeholder="Task title"
className={cn(
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
done && 'text-white/50 line-through',
)}
/>
</div>
<Textarea
value={notesField.value}
onChange={(e) => notesField.onChange(e.target.value)}
onFocus={notesField.onFocus}
onBlur={notesField.onBlur}
placeholder="Add notes…"
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
/>
<div className="flex flex-col gap-1.5">
{checklist.length > 0 && (
<span className="text-xs text-white/40">
{doneCount} / {checklist.length} done
</span>
)}
{checklist.map((item, index) => (
<ChecklistItemRow
// Index keys + whole-array writes are an accepted tradeoff:
// concurrent removal while someone types can shift focus.
key={index}
item={item}
onToggle={(checked) => handleToggleItem(index, checked)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
/>
))}
<AddChecklistItemRow onAdd={handleAddItem} />
</div>
<div className="flex items-center gap-2">
<Select
value={assigned_to ?? UNASSIGNED}
onValueChange={handleAssign}
>
<SelectTrigger
size="sm"
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
>
{assigned_to && assignee.exists && (
<HumanAvatar
size="sm"
initials={assignee.initials}
avatarObjectId={assignee.avatarObjectId}
/>
)}
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{network?.humans?.map((human) => (
<SelectItem key={human.id} value={human.id}>
{human.email_prefix}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
function ChecklistItemRow({
item,
onToggle,
onCommitText,
onRemove,
}: {
item: ChecklistItem;
onToggle: (checked: boolean) => void;
onCommitText: (text: string) => void;
onRemove: () => void;
}) {
const textField = useLiveDraftField({
remoteValue: item.text,
commit: onCommitText,
});
return (
<div className="group/item flex items-center gap-2.5">
<Checkbox
checked={item.done}
onCheckedChange={(checked) => onToggle(checked === true)}
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
}
/>
<input
value={textField.value}
onChange={(e) => textField.onChange(e.target.value)}
onFocus={textField.onFocus}
onBlur={textField.onBlur}
placeholder="Subtask"
className={cn(
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
item.done && 'text-white/40 line-through',
)}
/>
<button
type="button"
onClick={onRemove}
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
aria-label="Remove subtask"
>
<X className="size-3.5" />
</button>
</div>
);
}
function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
const [draft, setDraft] = useState('');
const submit = () => {
const text = draft.trim();
if (!text) return;
onAdd(text);
setDraft('');
};
return (
<div className="flex items-center gap-2.5">
<Plus className="size-4 text-white/30" />
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
onBlur={submit}
placeholder="Add subtask…"
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
/>
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import type { Particle } from '@/api/types';
import type { ParticlePath } from '@/lib/particle-path';
@@ -20,6 +20,7 @@ import { ParticleAttachments } from '@/features/particles/particle-attachments';
import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { useAuthStore } from '@/stores/auth-store';
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
import { MarkdownEditor } from '@/features/compose/markdown-editor';
type TextParticle = Extract<Particle, { type: 'text' }>;
@@ -36,7 +37,6 @@ interface TextParticleViewProps {
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
const EXTRA_S_PER_LINK = 2;
const EXTRA_S_PER_ATTACHMENT = 2;
@@ -97,29 +97,14 @@ export function TextParticleView({
urls.length,
attachments.length,
);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
useFixedDwell({
id: particle.id,
durationS,
paused,
onEnded,
onProgress,
});
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
@@ -0,0 +1,121 @@
import { useCallback, useMemo, useState } from 'react';
import { where } from 'firebase/firestore';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle } from '@/api/types';
const INITIAL_PAGE_SIZE = 9;
const PAGE_INCREMENT = 9;
// Stable references so the Firestore subscriptions don't re-attach per render.
const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
const CONTAINER_TYPE_FILTER = where('type', 'in', ['stream', 'folder']);
const LEAF_TYPE_FILTER = where('type', 'in', [
'media',
'file',
'text',
'task',
'paper',
]);
interface UseContainerChildrenOptions {
/**
* When set, list only streams with this status, filtered server-side like
* the pre-folder query (reuses its composite indexes). Used by the network
* root while folders are shelved; folders keep the full mixed-type behavior
* so the container model can be picked back up later. Only streams carry
* `status`, so the filter excludes other types by itself.
*/
streamStatus?: 'open' | 'closed';
}
interface UseContainerChildrenResult {
/** All visible children, sorted by latest activity (then creation). */
items: Particle[];
isLoading: boolean;
canLoadMore: boolean;
loadMore: () => void;
}
function activityTime(particle: Particle): number {
if (
(particle.type === 'stream' || particle.type === 'folder') &&
particle.last_child_created_at
) {
return particle.last_child_created_at.getTime();
}
return particle.created_at.getTime();
}
/**
* Children of a container (network root or folder), all particle types.
*
* Two merged subscriptions because visibility scoping filters on `visible_to`
* with array-contains-any, and leaf particles don't carry that field — a
* single scoped query would silently exclude them. Containers (streams,
* folders) are visibility-scoped; leaves inherit access from their container.
*/
export function useContainerChildren(
path: ParticlePath,
{ streamStatus }: UseContainerChildrenOptions = {},
): UseContainerChildrenResult {
const { networkId } = parseParticlePath(path);
const userId = useAuthStore((s) => s.user?.id);
const visibilityScopes = useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
const containerFilter = streamStatus
? streamStatus === 'open'
? OPEN_STATUS_FILTER
: CLOSED_STATUS_FILTER
: CONTAINER_TYPE_FILTER;
const { children: containers, isLoading: containersLoading } =
useLiveParticleChildren(path, {
orderByField: 'last_child_created_at',
orderDirection: 'desc',
visibilityScopes,
whereFilter: containerFilter,
limit,
});
// Passing undefined disables the subscription entirely in streams-only mode.
const { children: leaves, isLoading: leavesLoading } =
useLiveParticleChildren(streamStatus ? undefined : path, {
orderByField: 'created_at',
orderDirection: 'desc',
whereFilter: LEAF_TYPE_FILTER,
limit,
});
const items = useMemo(
() =>
[...containers, ...leaves].sort(
(a, b) => activityTime(b) - activityTime(a),
),
[containers, leaves],
);
// Heuristic: a query returning a full page may have more behind it.
const canLoadMore = containers.length >= limit || leaves.length >= limit;
const loadMore = useCallback(() => {
setLimit((prev) => prev + PAGE_INCREMENT);
}, []);
return {
items,
isLoading: containersLoading || leavesLoading,
canLoadMore,
loadMore,
};
}
+8 -3
View File
@@ -29,6 +29,8 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
type: T;
properties: ParticlePropertiesMap[T];
createdByHumanId: string;
// Required for container types
visibleTo?: string[];
}
export function useCreateParticle() {
@@ -59,6 +61,7 @@ export function useCreateParticle() {
params.type,
params.properties,
params.createdByHumanId,
params.visibleTo,
);
if (!CONTAINER_TYPES.has(params.type)) {
@@ -76,15 +79,17 @@ type CreateStreamParticleParams = {
properties: ParticlePropertiesMap['stream'];
createdByHumanId: string;
visibleTo?: string[];
// Container to create the stream in; defaults to the network root.
parentPath?: ParticlePath;
};
export function useCreateStreamParticle() {
return useMutation({
mutationFn: async (params: CreateStreamParticleParams) => {
const path = particlePath(params.networkId, []);
const networkCollectionPath = toFirestoreChildrenPath(path);
const path = params.parentPath ?? particlePath(params.networkId, []);
const collectionPath = toFirestoreChildrenPath(path);
return await createStreamParticle(
networkCollectionPath,
collectionPath,
params.properties,
params.createdByHumanId,
params.visibleTo,
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
const TICK_MS = 100;
interface UseFixedDwellOptions {
/** Reset key — restarts the timer when it changes (particle id). */
id: string;
durationS: number;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
/**
* Drives a fixed-duration dwell timer for particles without intrinsic
* playback (text, tasks, events): reports progress and fires onEnded once
* the duration elapses. Pausing freezes elapsed time rather than resetting.
*/
export function useFixedDwell({
id,
durationS,
paused,
onEnded,
onProgress,
}: UseFixedDwellOptions): void {
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
}, [id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, id]);
}
@@ -1,29 +1,33 @@
import { useEffect, useState } from 'react';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
interface UseStreamKeyboardNavOptions {
streams: Array<{ id: string }>;
interface UseListKeyboardNavOptions {
items: Array<{ id: string }>;
enabled: boolean;
onNavigate: (streamId: string) => void;
onOpen: (id: string) => void;
}
export function useStreamKeyboardNav({
streams,
/**
* Shared keyboard grammar for browsable lists (network root, folders):
* / move selection, Enter opens, 19 jump, V toggles video/audio.
*/
export function useListKeyboardNav({
items,
enabled,
onNavigate,
}: UseStreamKeyboardNavOptions) {
onOpen,
}: UseListKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(
streams.length > 0 ? 0 : null,
items.length > 0 ? 0 : null,
);
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
const [prevItemCount, setPrevItemCount] = useState(items.length);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Select the first stream once they load and clear when empty — but not on
// Select the first item once they load and clear when empty — but not on
// every Firestore update, which would scroll the list back to the top.
if (streams.length !== prevStreamCount) {
setPrevStreamCount(streams.length);
if (streams.length === 0) {
if (items.length !== prevItemCount) {
setPrevItemCount(items.length);
if (items.length === 0) {
setSelectedIndex(null);
} else if (selectedIndex === null) {
setSelectedIndex(0);
@@ -31,7 +35,7 @@ export function useStreamKeyboardNav({
}
useEffect(() => {
if (!enabled || streams.length === 0) return;
if (!enabled || items.length === 0) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
@@ -49,19 +53,19 @@ export function useStreamKeyboardNav({
const digit = parseInt(e.key, 10);
if (digit >= 1 && digit <= 9) {
const index = digit - 1;
if (index < streams.length) {
if (index < items.length) {
e.preventDefault();
onNavigate(streams[index].id);
onOpen(items[index].id);
}
return;
}
// Enter: navigate to selected
// Enter: open selected
if (e.key === 'Enter') {
setSelectedIndex((idx) => {
if (idx !== null && idx < streams.length) {
if (idx !== null && idx < items.length) {
e.preventDefault();
onNavigate(streams[idx].id);
onOpen(items[idx].id);
}
return idx;
});
@@ -86,7 +90,7 @@ export function useStreamKeyboardNav({
setSelectedIndex((prev) => {
if (prev === null) return 0;
const next = prev + delta;
return Math.max(0, Math.min(next, streams.length - 1));
return Math.max(0, Math.min(next, items.length - 1));
});
}
};
@@ -95,7 +99,7 @@ export function useStreamKeyboardNav({
// components (ToggleGroup, etc.) consume them for their own navigation.
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
}, [enabled, items, onOpen, recordingMode, setRecordingMode]);
return { selectedIndex };
}
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_DEBOUNCE_MS = 600;
interface UseLiveDraftFieldOptions {
remoteValue: string;
commit: (value: string) => void | Promise<void>;
debounceMs?: number;
}
export interface LiveDraftField {
value: string;
onChange: (value: string) => void;
onFocus: () => void;
onBlur: () => void;
}
/**
* State for a multiplayer always-editable text field: local draft while
* typing, debounced write-through, immediate flush on blur. Incoming remote
* values are applied only while the field is unfocused, so collaborators'
* snapshot updates never clobber in-progress typing (blur flushes pending
* writes synchronously, so unfocused implies no pending draft). Concurrent
* edits to the same field are last-write-wins.
*/
export function useLiveDraftField({
remoteValue,
commit,
debounceMs = DEFAULT_DEBOUNCE_MS,
}: UseLiveDraftFieldOptions): LiveDraftField {
const [value, setValue] = useState(remoteValue);
const [focused, setFocused] = useState(false);
// Sync from remote during render (derived-state pattern) unless the user
// is editing the field.
const [prevRemote, setPrevRemote] = useState(remoteValue);
if (remoteValue !== prevRemote) {
setPrevRemote(remoteValue);
if (!focused) {
setValue(remoteValue);
}
}
coderabbitai[bot] commented 2026-06-12 18:14:19 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major | Quick win

Defer the remote sync instead of consuming it while focused.

Line 38 advances prevRemote even when the field is focused, but Line 40 skips applying that value. If a collaborator changes the field while this input merely has focus, blur won't revisit the sync path and the local UI stays stale until some later remote update arrives. Keep the latest remote value pending while focused, then apply it after focus leaves when there is no local draft to flush.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/hooks/use-live-draft-field.ts` around lines 34 - 42, The
current logic advances prevRemote even while focused, preventing a pending
remote update from being applied on blur; change the sync so you only call
setPrevRemote(remoteValue) when not focused (or when there's no local draft to
flush) and leave prevRemote unchanged while focused so the inequality stays
true; also ensure your blur handler (or the focused state transition in
useLiveDraftField) checks if remoteValue !== prevRemote and, when focus is lost
and there is no local draft to keep, calls setPrevRemote(remoteValue) and
setValue(remoteValue) to apply the pending remote update.
_⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_ **Defer the remote sync instead of consuming it while focused.** Line 38 advances `prevRemote` even when the field is focused, but Line 40 skips applying that value. If a collaborator changes the field while this input merely has focus, blur won't revisit the sync path and the local UI stays stale until some later remote update arrives. Keep the latest remote value pending while focused, then apply it after focus leaves when there is no local draft to flush. <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/hooks/use-live-draft-field.ts` around lines 34 - 42, The current logic advances prevRemote even while focused, preventing a pending remote update from being applied on blur; change the sync so you only call setPrevRemote(remoteValue) when not focused (or when there's no local draft to flush) and leave prevRemote unchanged while focused so the inequality stays true; also ensure your blur handler (or the focused state transition in useLiveDraftField) checks if remoteValue !== prevRemote and, when focus is lost and there is no local draft to keep, calls setPrevRemote(remoteValue) and setValue(remoteValue) to apply the pending remote update. ``` </details> <!-- fingerprinting:phantom:medusa:grasshopper --> <!-- cr-comment:v1:4f69933d7e3c282877b6ee87 --> <!-- This is an auto-generated comment by CodeRabbit -->
// Written only in handlers; read by flush() so its identity stays stable.
const valueRef = useRef(remoteValue);
const pendingRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const commitRef = useRef(commit);
useEffect(() => {
commitRef.current = commit;
}, [commit]);
const flush = useCallback(() => {
clearTimeout(timerRef.current);
timerRef.current = undefined;
if (!pendingRef.current) return;
pendingRef.current = false;
void commitRef.current(valueRef.current);
}, []);
// Flush any pending write when the field unmounts (e.g. playback advances).
useEffect(() => flush, [flush]);
const onChange = useCallback(
(next: string) => {
valueRef.current = next;
setValue(next);
pendingRef.current = true;
clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, debounceMs);
},
[debounceMs, flush],
);
const onFocus = useCallback(() => {
setFocused(true);
}, []);
const onBlur = useCallback(() => {
setFocused(false);
flush();
}, [flush]);
return { value, onChange, onFocus, onBlur };
}
+17 -2
View File
@@ -1,4 +1,10 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from 'react';
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import {
@@ -15,6 +21,10 @@ interface UsePlaybackKeysOptions {
interface UsePlaybackKeysResult {
fastPlayback: boolean;
/** True while playback is paused by the user's space toggle/hold. */
spacePaused: boolean;
/** Clear the user's space pause (e.g. clicking the on-screen play button). */
resume: () => void;
}
/**
@@ -31,6 +41,11 @@ export function usePlaybackKeys({
useSuspendPlayback(spaceHeld, 'hold-space');
const resume = useCallback(() => {
setSpaceHeld(false);
spaceStartRef.current = 0;
}, []);
useEffect(() => {
const isExternallyPaused = () =>
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
@@ -92,5 +107,5 @@ export function usePlaybackKeys({
};
}, [mediaRef, spaceHeld]);
return { fastPlayback };
return { fastPlayback, spacePaused: spaceHeld, resume };
}
@@ -11,6 +11,7 @@ interface UseStreamNavigationKeysOptions {
childrenLength: number;
mediaRef: RefObject<MediaParticleHandle | null>;
onExit: () => void;
onToggleViewMode: () => void;
}
/**
@@ -24,6 +25,7 @@ export function useStreamNavigationKeys({
childrenLength,
mediaRef,
onExit,
onToggleViewMode,
}: UseStreamNavigationKeysOptions) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -54,10 +56,25 @@ export function useStreamNavigationKeys({
e.preventDefault();
onExit();
break;
case 'l':
case 'L':
// Registered here (not in action keys) because the toggle must work
// in list mode, where playback is suspended and action keys bail.
e.preventDefault();
onToggleViewMode();
break;
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
}, [
next,
prev,
currentIndex,
childrenLength,
mediaRef,
onExit,
onToggleViewMode,
]);
}
@@ -1,80 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { where, type QueryFieldFilterConstraint } from 'firebase/firestore';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from '@/api/types';
export type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
const INITIAL_PAGE_SIZE = 12;
const PAGE_INCREMENT = 12;
// Stable where-constraint references so the Firestore subscription only
// re-attaches when the tab actually changes, not on every render.
const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
if (networkId) scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
}
interface UseStreamParticlesOptions {
// Which streams to subscribe to
status: 'open' | 'closed';
}
interface UseStreamParticlesResult {
streams: StreamParticle[];
isLoading: boolean;
networkId: string;
/** True when more streams may exist beyond the current window. */
canLoadMore: boolean;
/** Extend the pagination window. */
loadMore: () => void;
}
export function useStreamParticles(
path: ParticlePath,
{ status }: UseStreamParticlesOptions,
): UseStreamParticlesResult {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
const whereFilter: QueryFieldFilterConstraint =
status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
const { children, isLoading } = useLiveParticleChildren(path, {
orderByField: 'last_child_created_at',
orderDirection: 'desc',
visibilityScopes,
whereFilter,
limit,
});
const streams = useMemo(
() => children.filter((c): c is StreamParticle => c.type === 'stream'),
[children],
);
// Heuristic: if we got back as many items as we asked for, assume there
// might be more. Clicking load-more when there are no more is a no-op.
const canLoadMore = streams.length >= limit;
const loadMore = useCallback(() => {
setLimit((prev) => prev + PAGE_INCREMENT);
}, []);
return { streams, isLoading, networkId, canLoadMore, loadMore };
}
@@ -98,12 +98,26 @@ interface UseStreamPlaybackResult {
goToParticle: (particleId: string) => void;
}
interface UseStreamPlaybackOptions {
/**
* When false, newly arriving particles don't pull playback forward after
* it has ended (list mode browses; selection must stay put). Default true.
*/
autoAdvanceOnNew?: boolean;
}
export function useStreamPlayback(
streamParticle: Particle & { type: 'stream' },
path: ParticlePath,
{ autoAdvanceOnNew = true }: UseStreamPlaybackOptions = {},
): UseStreamPlaybackResult {
const userId = useAuthStore((s) => s.user?.id);
const [state, dispatch] = useReducer(playbackReducer, initialState);
// Read via ref so the onAdded subscription callback stays stable.
const autoAdvanceOnNewRef = useRef(autoAdvanceOnNew);
useEffect(() => {
autoAdvanceOnNewRef.current = autoAdvanceOnNew;
}, [autoAdvanceOnNew]);
// Track the stream ID we've initialized for, to reset when navigating between streams
const initializedForRef = useRef<string | null>(null);
// Latest currentIndex for onParticleRemoved, which is passed into
@@ -115,6 +129,7 @@ export function useStreamPlayback(
// --- Firestore change callbacks ---
const onParticleAdded = useCallback((particle: Particle) => {
if (!autoAdvanceOnNewRef.current) return;
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
}, []);
@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
import type { Particle } from '@/api/types';
export type StreamViewMode = 'player' | 'list';
function decideMode(
streamParticle: Particle & { type: 'stream' },
userId: string | undefined,
): StreamViewMode {
const marker = userId ? streamParticle.playback_markers?.[userId] : undefined;
const lastChildAt = streamParticle.last_child_created_at;
const caughtUp =
!!marker && !!lastChildAt && lastChildAt.getTime() <= marker.getTime();
return caughtUp ? 'list' : 'player';
}
/**
* Which mode a stream opens in: the player (autoplay catch-up) when there's
* unseen content, the browsable list when the user is fully caught up.
* Decided once on entry from the playback marker vs. the stream's last
* activity — browsing afterwards advances the marker, but the mode only
* changes via the user's toggle.
*/
export function useStreamViewMode(
streamParticle: Particle & { type: 'stream' },
userId: string | undefined,
): { mode: StreamViewMode; toggle: () => void } {
const [mode, setMode] = useState<StreamViewMode>(() =>
decideMode(streamParticle, userId),
);
// Re-decide when navigating between streams without an unmount.
const [prevStreamId, setPrevStreamId] = useState(streamParticle.id);
if (prevStreamId !== streamParticle.id) {
setPrevStreamId(streamParticle.id);
setMode(decideMode(streamParticle, userId));
}
coderabbitai[bot] commented 2026-06-12 18:14:19 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

React 19 best practice: is calling setState during render (guarded by prop/id comparison) recommended, or should this synchronization be done in useEffect?

💡 Result:

In React 19, calling setState directly during render (the component body) is generally considered an anti-pattern and is discouraged [1][2][3]. The best practice is to derive state during render whenever possible or move the logic to an event handler [1][4][5]. The Core Principles: 1. Prefer Derivation Over State: If a value can be calculated from existing props or state, you should calculate it directly during rendering instead of storing it in a separate state variable [4][5][6]. This eliminates the need for synchronization entirely, avoids extra re-renders, and prevents bugs caused by state getting out of sync [4][5]. 2. Avoid setState in Render: Calling setState during render is dangerous because it can trigger infinite render loops and degrade performance by causing "cascading" updates (where React must discard the current render to start a new one) [1][2][5]. While you can technically guard setState with a condition (e.g., if (props.id !== prevId)) to prevent loops, this is a last resort and is often a sign that your component's state structure should be re-evaluated [1][5]. 3. Use useEffect as a Last Resort: If you must synchronize state based on props (e.g., resetting state when an ID changes), it is generally better to perform this in an event handler (the moment the change is triggered) rather than in an effect [1][4][5]. useEffect should be reserved for synchronizing with external systems (e.g., browser APIs, subscriptions) rather than synchronizing internal React state [6][7]. Synchronous setState in an effect is also inefficient because it forces an extra render cycle [4][8]. Summary of Recommendations: - If you can calculate the data from props or other state: Perform the calculation directly in the component body [4][5]. - If you need to reset/change state when a prop changes: Move the logic to the event handler where the prop (or its source) is updated [1][5]. - If you must use an effect: Ensure it is truly necessary for external synchronization, not just internal state management [6][7]. By moving logic into event handlers or deriving values during render, you align with React's philosophy of maintaining pure, predictable components [5][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant portion of the file around the referenced lines
FILE="js/desktop/src/hooks/use-stream-view-mode.ts"
nl -ba "$FILE" | sed -n '1,120p'

Repository: flowy-live/llink

Length of output: 103


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="js/desktop/src/hooks/use-stream-view-mode.ts"

# Print a small window around the reported lines; avoid `nl` (not available in sandbox)
sed -n '1,120p' "$FILE" | sed -n '20,80p'

Repository: flowy-live/llink

Length of output: 927


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="js/desktop/src/hooks/use-stream-view-mode.ts"

# Show imports + surrounding helpers
sed -n '1,60p' "$FILE"

Repository: flowy-live/llink

Length of output: 1620


Move stream-change synchronization out of render phase in useStreamViewMode.

js/desktop/src/hooks/use-stream-view-mode.ts updates state inside the component body (setPrevStreamId/setMode in the if block at lines 35-36). This is a React anti-pattern that can trigger warnings/extra render churn. Reset mode in a useEffect keyed to the stream identity instead.

♻️ Suggested refactor
-import { useCallback, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
@@
-  // Re-decide when navigating between streams without an unmount.
-  const [prevStreamId, setPrevStreamId] = useState(streamParticle.id);
-  if (prevStreamId !== streamParticle.id) {
-    setPrevStreamId(streamParticle.id);
-    setMode(decideMode(streamParticle, userId));
-  }
+  useEffect(() => {
+    setMode(decideMode(streamParticle, userId));
+  }, [streamParticle.id, userId]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  useEffect(() => {
    setMode(decideMode(streamParticle, userId));
  }, [streamParticle.id, userId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/hooks/use-stream-view-mode.ts` around lines 32 - 37, The state
updates for stream changes are happening during render inside useStreamViewMode
(the if block that calls setPrevStreamId and setMode with
prevStreamId/streamParticle.id), which is an anti-pattern; move that logic into
a useEffect: keep prevStreamId and mode state as-is but remove the in-render if,
and create a useEffect keyed on streamParticle.id (and userId if decideMode
depends on it) that compares prevStreamId to streamParticle.id and calls
setPrevStreamId(streamParticle.id) and setMode(decideMode(streamParticle,
userId)) when the id changes so updates occur after render and avoid extra
render churn and warnings.
_⚠️ Potential issue_ | _🟠 Major_ <details> <summary>🧩 Analysis chain</summary> 🌐 Web query: `React 19 best practice: is calling setState during render (guarded by prop/id comparison) recommended, or should this synchronization be done in useEffect?` 💡 Result: In React 19, calling `setState` directly during render (the component body) is generally considered an anti-pattern and is discouraged [1][2][3]. The best practice is to derive state during render whenever possible or move the logic to an event handler [1][4][5]. The Core Principles: 1. Prefer Derivation Over State: If a value can be calculated from existing props or state, you should calculate it directly during rendering instead of storing it in a separate state variable [4][5][6]. This eliminates the need for synchronization entirely, avoids extra re-renders, and prevents bugs caused by state getting out of sync [4][5]. 2. Avoid `setState` in Render: Calling `setState` during render is dangerous because it can trigger infinite render loops and degrade performance by causing "cascading" updates (where React must discard the current render to start a new one) [1][2][5]. While you can technically guard `setState` with a condition (e.g., `if (props.id !== prevId)`) to prevent loops, this is a last resort and is often a sign that your component's state structure should be re-evaluated [1][5]. 3. Use `useEffect` as a Last Resort: If you must synchronize state based on props (e.g., resetting state when an ID changes), it is generally better to perform this in an event handler (the moment the change is triggered) rather than in an effect [1][4][5]. `useEffect` should be reserved for synchronizing with external systems (e.g., browser APIs, subscriptions) rather than synchronizing internal React state [6][7]. Synchronous `setState` in an effect is also inefficient because it forces an extra render cycle [4][8]. Summary of Recommendations: - If you can calculate the data from props or other state: Perform the calculation directly in the component body [4][5]. - If you need to reset/change state when a prop changes: Move the logic to the event handler where the prop (or its source) is updated [1][5]. - If you must use an effect: Ensure it is truly necessary for external synchronization, not just internal state management [6][7]. By moving logic into event handlers or deriving values during render, you align with React's philosophy of maintaining pure, predictable components [5][3]. Citations: - 1: https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-render - 2: https://knowledgelib.io/software/debugging/react-too-many-rerenders/2026 - 3: https://react.dev/reference/rules/components-and-hooks-must-be-pure - 4: https://uk.react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect - 5: https://react.dev/learn/you-might-not-need-an-effect - 6: https://juanchi.dev/en/blog/why-i-stopped-using-useeffect-sync-state-react-19 - 7: https://react.dev/reference/react/useEffect - 8: https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail # Show the relevant portion of the file around the referenced lines FILE="js/desktop/src/hooks/use-stream-view-mode.ts" nl -ba "$FILE" | sed -n '1,120p' ``` Repository: flowy-live/llink Length of output: 103 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail FILE="js/desktop/src/hooks/use-stream-view-mode.ts" # Print a small window around the reported lines; avoid `nl` (not available in sandbox) sed -n '1,120p' "$FILE" | sed -n '20,80p' ``` Repository: flowy-live/llink Length of output: 927 --- 🏁 Script executed: ```shell #!/bin/bash set -euo pipefail FILE="js/desktop/src/hooks/use-stream-view-mode.ts" # Show imports + surrounding helpers sed -n '1,60p' "$FILE" ``` Repository: flowy-live/llink Length of output: 1620 --- </details> **Move stream-change synchronization out of render phase in `useStreamViewMode`.** `js/desktop/src/hooks/use-stream-view-mode.ts` updates state inside the component body (`setPrevStreamId`/`setMode` in the `if` block at lines 35-36). This is a React anti-pattern that can trigger warnings/extra render churn. Reset `mode` in a `useEffect` keyed to the stream identity instead. <details> <summary>♻️ Suggested refactor</summary> ```diff -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; @@ - // Re-decide when navigating between streams without an unmount. - const [prevStreamId, setPrevStreamId] = useState(streamParticle.id); - if (prevStreamId !== streamParticle.id) { - setPrevStreamId(streamParticle.id); - setMode(decideMode(streamParticle, userId)); - } + useEffect(() => { + setMode(decideMode(streamParticle, userId)); + }, [streamParticle.id, userId]); ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion useEffect(() => { setMode(decideMode(streamParticle, userId)); }, [streamParticle.id, userId]); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/hooks/use-stream-view-mode.ts` around lines 32 - 37, The state updates for stream changes are happening during render inside useStreamViewMode (the if block that calls setPrevStreamId and setMode with prevStreamId/streamParticle.id), which is an anti-pattern; move that logic into a useEffect: keep prevStreamId and mode state as-is but remove the in-render if, and create a useEffect keyed on streamParticle.id (and userId if decideMode depends on it) that compares prevStreamId to streamParticle.id and calls setPrevStreamId(streamParticle.id) and setMode(decideMode(streamParticle, userId)) when the id changes so updates occur after render and avoid extra render churn and warnings. ``` </details> <!-- fingerprinting:phantom:poseidon:hawk --> <!-- cr-comment:v1:e50fec881ef48a0fe8d87141 --> <!-- This is an auto-generated comment by CodeRabbit -->
const toggle = useCallback(() => {
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
}, []);
return { mode, toggle };
}
+100 -31
View File
@@ -23,7 +23,11 @@ import {
QueryFieldFilterConstraint,
} from 'firebase/firestore';
import { firestoreDb } from '@/firebase';
import { isContainerType, ParticleSchema } from '@/api/types';
import {
isContainerType,
ParticleSchema,
UnknownParticleSchema,
} from '@/api/types';
import type {
Particle,
ParticleType,
@@ -33,6 +37,22 @@ import type {
// --- Converter ---
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Coerce the
// per-type property fields that carry timestamps.
function coerceLeafPropertyDates(
type: ParticleType,
properties: DocumentData | undefined,
): DocumentData | undefined {
if (!properties) return properties;
if (type === 'text' && properties.edited_at) {
return {
...properties,
edited_at: (properties.edited_at as Timestamp).toDate(),
};
}
return properties;
}
const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle;
@@ -60,6 +80,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
id: snap.id,
type: raw.type,
properties: raw.properties,
status: raw.status ?? undefined,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at
@@ -79,7 +100,6 @@ const particleConverter: FirestoreDataConverter<Particle> = {
: undefined,
huddle_active_participants:
raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined,
});
case 'folder':
return ParticleSchema.parse({
@@ -92,21 +112,16 @@ const particleConverter: FirestoreDataConverter<Particle> = {
? (raw.updated_at as Timestamp).toDate()
: undefined,
visible_to: raw.visible_to,
last_child_created_at: raw.last_child_created_at
? (raw.last_child_created_at as Timestamp).toDate()
: undefined,
});
case 'media':
case 'file':
case 'text':
case 'quest':
case 'task':
case 'paper': {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present.
const properties =
type === 'text' && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
}
: raw.properties;
const properties = coerceLeafPropertyDates(type, raw.properties);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
@@ -124,11 +139,33 @@ const particleConverter: FirestoreDataConverter<Particle> = {
});
}
default:
throw new Error(`Unknown particle type: ${type}`);
// Forward compatibility: keep unrecognized types visible as
// placeholders instead of breaking the snapshot they arrive in.
return UnknownParticleSchema.parse({
id: snap.id,
type: 'unknown',
raw_type: raw.type,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at
? (raw.updated_at as Timestamp).toDate()
: undefined,
});
}
},
};
// Corrupt docs (malformed base fields, schema parse failures) shouldn't take
// down a whole subscription — skip just the bad doc and keep the rest.
function safeData(snap: QueryDocumentSnapshot<Particle>): Particle | null {
try {
return snap.data();
} catch (err) {
console.warn(`Skipping unparseable particle at ${snap.ref.path}:`, err);
return null;
}
}
// --- Typed reference helpers ---
function typedDoc(path: string) {
@@ -149,7 +186,7 @@ export function subscribeToParticle(
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
onData(snap.exists() ? safeData(snap) : null);
},
onError,
);
@@ -161,7 +198,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
return null;
}
return doc.data();
return safeData(doc);
}
export interface GetParticleChildrenOptions {
@@ -184,7 +221,7 @@ export async function getParticleChildren(
orderBy(orderByField, orderDirection),
);
const snap = await getDocs(q);
return snap.docs.map((d) => d.data());
return snap.docs.flatMap((d) => safeData(d) ?? []);
}
export interface SubscribeToParticleChildrenOptions {
@@ -230,14 +267,16 @@ export function subscribeToParticleChildren(
return onSnapshot(
q,
(snap) => {
const updatedChildren = snap.docs.map((d) => d.data());
const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
const child = safeData(change.doc);
if (!child) continue;
if (change.type === 'added' && onAdded) onAdded(child);
if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren);
onRemoved(child, updatedChildren);
}
}
},
@@ -258,12 +297,31 @@ export function subscribeToLatestChild(
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
onData(snap.empty ? null : safeData(snap.docs[0]));
},
onError,
);
}
// Best-effort bump of the parent container's last_child_created_at when a
// child is created. The particle processor worker does this for stream
// parents, but skips folders, so the client keeps folder activity fresh
// itself. Same value semantics as the worker: the child's created_at, so it
// stays directly comparable with playback markers.
function bumpParentLastChildCreatedAt(
collectionPath: string,
childCreatedAt: Date,
): void {
const parentDocPath = collectionPath.replace(/\/children$/, '');
// The network root (networks/{id}) is not a particle doc — nothing to bump.
if (!parentDocPath.includes('/children/')) return;
updateDoc(doc(firestoreDb, parentDocPath), {
last_child_created_at: Timestamp.fromDate(childCreatedAt),
}).catch(() => {
// Non-fatal: ordering freshness only.
});
}
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
collectionPath: string,
@@ -279,15 +337,21 @@ export async function createParticle<T extends ParticleType>(
);
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '', // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_at: createdAt,
created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}),
// Containers start with last_child_created_at = created_at so they appear
// in activity-ordered queries before they have any children (Firestore
// orderBy drops docs missing the field).
...(isContainerType(type) ? { last_child_created_at: createdAt } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}
@@ -301,19 +365,32 @@ export async function createStreamParticle(
throw new Error('visibleTo is required for streams and cannot be empty');
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '',
type: 'stream',
properties,
created_at: new Date(),
status: 'open',
created_at: createdAt,
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: 'open',
last_child_created_at: createdAt,
});
const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}
export async function updateStreamStatus(
docPath: string,
status: 'open' | 'closed',
): Promise<void> {
await updateDoc(typedDoc(docPath), {
status,
updated_at: serverTimestamp(),
});
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
@@ -382,14 +459,6 @@ export async function updateParticle(
});
}
export async function updateStreamStatus(
docPath: string,
status: 'open' | 'closed',
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
}
/**
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
* in place so concurrent viewers see the deletion inline rather than being
+91
View File
@@ -0,0 +1,91 @@
import {
CircleCheck,
FileText,
Folder,
HelpCircle,
Image,
MessageSquare,
Mic,
Radio,
StickyNote,
Trash2,
Video,
type LucideIcon,
} from 'lucide-react';
import { isParticleDeleted, type Particle } from '@/api/types';
export function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case 'stream':
return Radio;
case 'folder':
return Folder;
case 'text':
return MessageSquare;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith('image/')) return Image;
return Video;
}
case 'file':
return FileText;
case 'task':
return CircleCheck;
case 'paper':
return StickyNote;
case 'unknown':
return HelpCircle;
}
}
export function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'text':
return particle.properties.content;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('image/')) return 'Photo';
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
}
return 'Media';
}
case 'file':
return particle.properties.filename;
case 'task':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'unknown':
return 'Unsupported particle';
}
}
export function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'task':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
case 'unknown':
return 'Unsupported particle';
}
}
@@ -10,6 +10,7 @@ import { create } from 'zustand';
* |--------|-----------------|--------------------------------------------|
* | record | idle | start a media (camera/mic) recording |
* | text | idle | open the text compose step |
* | task | idle | open the task compose step |
* | stop | recording | finish recording → review |
* | cancel | recording, etc. | abort recording / discard review |
* | send | reviewing | submit the recorded particle |
@@ -18,7 +19,13 @@ import { create } from 'zustand';
* ultimately invoke the same callbacks the intent dispatcher does, so the
* guard logic stays in one place.
*/
export type ComposeIntent = 'record' | 'text' | 'stop' | 'cancel' | 'send';
export type ComposeIntent =
| 'record'
| 'text'
| 'task'
| 'stop'
| 'cancel'
| 'send';
interface ComposeIntentState {
intent: { kind: ComposeIntent } | null;
+26 -7
View File
@@ -16,6 +16,7 @@ import {
arrayRemove,
FieldPath,
type DocumentData,
type DocumentSnapshot,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
@@ -129,6 +130,18 @@ const particleConverter: FirestoreDataConverter<Particle> = {
},
};
// Parse a snapshot defensively: newer clients may write particle types this
// app version doesn't know yet. A single unparseable doc must not break the
// whole subscription, so skip it instead of throwing inside onSnapshot.
function safeData(snap: DocumentSnapshot<Particle>): Particle | null {
try {
return snap.data() ?? null;
} catch (error) {
console.warn(`Skipping unparseable particle ${snap.ref.path}:`, error);
return null;
}
}
// --- Typed reference helpers ---
function typedDoc(path: string) {
@@ -149,7 +162,7 @@ export function subscribeToParticle(
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
onData(safeData(snap));
},
onError,
);
@@ -160,7 +173,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
if (!docSnap.exists()) {
return null;
}
return docSnap.data();
return safeData(docSnap);
}
export interface GetParticleChildrenOptions {
@@ -183,7 +196,9 @@ export async function getParticleChildren(
orderBy(orderByField, orderDirection),
);
const snap = await getDocs(q);
return snap.docs.map((d) => d.data());
return snap.docs
.map((d) => safeData(d))
.filter((p): p is Particle => p !== null);
}
export interface SubscribeToParticleChildrenOptions {
@@ -229,14 +244,18 @@ export function subscribeToParticleChildren(
return onSnapshot(
q,
(snap) => {
const updatedChildren = snap.docs.map((d) => d.data());
const updatedChildren = snap.docs
.map((d) => safeData(d))
.filter((p): p is Particle => p !== null);
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
const changed = safeData(change.doc);
if (!changed) continue;
if (change.type === 'added' && onAdded) onAdded(changed);
if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren);
onRemoved(changed, updatedChildren);
}
}
},
@@ -257,7 +276,7 @@ export function subscribeToLatestChild(
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
onData(snap.empty ? null : safeData(snap.docs[0]));
},
onError,
);