feat: stream list view and tasks (#279)

* first attempt at stream sidebar, tasks, and events

* fix folder from root

* cleanup folders and events, and condense changes

* cleanup and add toggle for sidebar

* cleanup

* fix nits
This commit was merged in pull request #279.
This commit is contained in:
Arjun Patel
2026-06-12 12:26:49 -07:00
committed by GitHub
parent a358774106
commit 095b9876f9
37 changed files with 2384 additions and 791 deletions
@@ -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);
},
[
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();
}}
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>
@@ -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],
);
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);
}}
>
<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();