first attempt at stream sidebar, tasks, and events

This commit is contained in:
Arjun Patel
2026-06-11 22:38:55 -07:00
parent 9e3a30f74e
commit 1baae790c6
219 changed files with 7463 additions and 809 deletions
@@ -19,7 +19,10 @@ 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 { EventComposeStep } from '@/features/compose/event-compose-step';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import type { TaskProperties, EventProperties } from '@/api/types';
import { apiClient } from '@/api/client';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { useMediaDevicesStore } from '@/stores/media-devices-store';
@@ -40,11 +43,17 @@ export type ComposeStep =
| 'recording'
| 'reviewing'
| 'typing'
| 'task'
| 'event'
| 'configuring'
| 'submitting';
type RecordingSource = 'media' | 'screen';
type PendingArtifact =
| { type: 'task'; properties: TaskProperties }
| { type: 'event'; properties: EventProperties };
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
@@ -52,8 +61,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 +75,6 @@ export function ComposeOverlay({
onActiveChange,
onStepChange,
onParticleCreated,
disabled,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>('idle');
const [error, setError] = useState<string | null>(null);
@@ -98,14 +104,15 @@ 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);
// Task/event captured by their compose steps, created on submit. A ref (not
// state) so submit handlers can set it and create in the same tick.
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 +149,7 @@ export function ComposeOverlay({
setReviewDurationMs(0);
setReviewMimeType(null);
setRecordingSource('media');
pendingArtifactRef.current = null;
setAttachments((prev) => {
revokeAttachmentThumbnails(prev);
return [];
@@ -330,7 +338,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',
@@ -458,10 +474,6 @@ export function ComposeOverlay({
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 +496,31 @@ export function ComposeOverlay({
setStepSync('typing');
}, [guardIdle, setStepSync]);
const handleTaskIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('task');
}, [guardIdle, setStepSync]);
const handleEventIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('event');
}, [guardIdle, setStepSync]);
// Task/event 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 +574,12 @@ export function ComposeOverlay({
case 'text':
handleTextIntent();
break;
case 'task':
handleTaskIntent();
break;
case 'event':
handleEventIntent();
break;
case 'stop':
handleStopIntent();
break;
@@ -552,6 +595,8 @@ export function ComposeOverlay({
}, [
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleEventIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
@@ -566,6 +611,8 @@ export function ComposeOverlay({
if (
currentStep === 'typing' ||
currentStep === 'task' ||
currentStep === 'event' ||
currentStep === 'configuring' ||
currentStep === 'picking'
) {
@@ -599,6 +646,12 @@ 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();
} else if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
handleEventIntent();
}
break;
}
@@ -666,6 +719,8 @@ export function ComposeOverlay({
guardIdle,
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleEventIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
@@ -774,8 +829,25 @@ export function ComposeOverlay({
dropZoneProps={dropZoneProps}
/>
)}
{step === 'task' && (
<TaskComposeStep
networkId={networkId}
onCancel={cancel}
onSubmit={(properties) =>
handleArtifactSubmit({ type: 'task', properties })
}
/>
)}
{step === 'event' && (
<EventComposeStep
onCancel={cancel}
onSubmit={(properties) =>
handleArtifactSubmit({ type: 'event', 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,126 @@
import { useCallback, useState } from 'react';
import type { EventProperties } from '@/api/types';
import { metaKey } from '@/lib/platform';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { KeyHint } from '@/components/key-hint';
import { toDatetimeLocalValue } from '@/features/particles/event-particle-view';
interface EventComposeStepProps {
onCancel: () => void;
onSubmit: (properties: EventProperties) => void;
}
function nextFullHour(): Date {
const date = new Date();
date.setMinutes(0, 0, 0);
date.setHours(date.getHours() + 1);
return date;
}
const dateInputClass =
'w-full rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white outline-none [color-scheme:dark] focus:border-white/30';
export function EventComposeStep({
onCancel,
onSubmit,
}: EventComposeStepProps) {
const [title, setTitle] = useState('');
const [startAt, setStartAt] = useState(() =>
toDatetimeLocalValue(nextFullHour()),
);
const [endAt, setEndAt] = useState('');
const [notes, setNotes] = useState('');
const handleSubmit = useCallback(() => {
const trimmed = title.trim();
if (!trimmed || !startAt) return;
onSubmit({
title: trimmed,
start_at: new Date(startAt),
...(endAt && { end_at: new Date(endAt) }),
...(notes.trim() && { notes: notes.trim() }),
});
}, [title, startAt, endAt, notes, 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">Event</Label>
<Input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What's happening?"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<Label className="mb-1 text-xs text-white/50">Starts</Label>
<input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
className={dateInputClass}
/>
</div>
<div className="flex-1">
<Label className="mb-1 text-xs text-white/50">Ends</Label>
<input
type="datetime-local"
value={endAt}
min={startAt}
onChange={(e) => setEndAt(e.target.value)}
className={dateInputClass}
/>
</div>
</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>
<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 event (or press ${metaKey}+Enter)`}
>
create
</KeyHint>
</div>
</div>
);
}
@@ -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>
);
}