From e26c0578a30447987734a4f817689a15357b6667 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Wed, 20 May 2026 00:40:24 -0400 Subject: [PATCH] feat(blocks): add video, plan, and task_card block support Adds builder support for the three remaining Slack block types the validator already understands but the palette and editor surface didn't expose: `video` (embedded player with provider metadata), `plan` (agent checklist), and `task_card` (tracked agent step with sources). New palette variants ship in the Agents and Video sections; the per-block popover editor dispatches to a typed form for each. All factory variants round-trip through `toSlackBlocks` and pass `validateBlockKit` in the existing `public-api` palette test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/block-row.tsx | 5 +- src/components/editors/block-editor.tsx | 14 +- src/components/editors/plan-editor.tsx | 118 ++++++++++++++++ src/components/editors/task-card-editor.tsx | 141 ++++++++++++++++++++ src/components/editors/video-editor.tsx | 137 +++++++++++++++++++ src/index.ts | 7 +- src/lib/default-blocks.ts | 106 ++++++++++++++- src/types.ts | 79 ++++++++++- 8 files changed, 601 insertions(+), 6 deletions(-) create mode 100644 src/components/editors/plan-editor.tsx create mode 100644 src/components/editors/task-card-editor.tsx create mode 100644 src/components/editors/video-editor.tsx diff --git a/src/components/block-row.tsx b/src/components/block-row.tsx index a57c1cb..cf3db45 100644 --- a/src/components/block-row.tsx +++ b/src/components/block-row.tsx @@ -26,7 +26,10 @@ const BLOCK_TYPE_LABELS: Record = { card: 'Card', carousel: 'Carousel', context_actions: 'Context Actions', - input: 'Input' + input: 'Input', + video: 'Video', + plan: 'Plan', + task_card: 'Task Card' }; /** diff --git a/src/components/editors/block-editor.tsx b/src/components/editors/block-editor.tsx index 8fdf2a2..3bd310d 100644 --- a/src/components/editors/block-editor.tsx +++ b/src/components/editors/block-editor.tsx @@ -14,9 +14,12 @@ import type { ContextActionsBlock, InputBlock, MarkdownBlock, + PlanBlock, SupportedBlock, SupportedBlockType, - TableBlock + TableBlock, + TaskCardBlock, + VideoBlock } from '../../types'; import { ActionsEditor } from './actions-editor'; import { AlertEditor } from './alert-editor'; @@ -29,9 +32,12 @@ import { HeaderEditor } from './header-editor'; import { ImageEditor } from './image-editor'; import { InputEditor } from './input-editor'; import { MarkdownEditor } from './markdown-editor'; +import { PlanEditor } from './plan-editor'; import { RichTextEditor } from './rich-text-editor'; import { SectionEditor } from './section-editor'; import { TableEditor } from './table-editor'; +import { TaskCardEditor } from './task-card-editor'; +import { VideoEditor } from './video-editor'; /** * Dispatches to the correct per-block editor form. Provides a consistent @@ -107,6 +113,12 @@ function dispatch(block: SupportedBlock, onChange: (next: SupportedBlock) => voi return onChange(next)} />; case 'input': return onChange(next)} />; + case 'video': + return onChange(next)} />; + case 'plan': + return onChange(next)} />; + case 'task_card': + return onChange(next)} />; default: return null; } diff --git a/src/components/editors/plan-editor.tsx b/src/components/editors/plan-editor.tsx new file mode 100644 index 0000000..1f9dbd5 --- /dev/null +++ b/src/components/editors/plan-editor.tsx @@ -0,0 +1,118 @@ +import { Plus, Trash2 } from 'lucide-react'; +import { nanoid } from 'nanoid'; +import { Button } from '../../lib/ui/button'; +import { Input } from '../../lib/ui/input'; +import { Label } from '../../lib/ui/label'; +import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group'; +import type { PlanBlock, TaskCardBlock, TaskCardStatus } from '../../types'; +import { EditorField } from './field'; +import type { BlockEditorProps } from './types'; + +const STATUSES: readonly TaskCardStatus[] = ['pending', 'in_progress', 'complete', 'error'] as const; + +/** + * Editor form for plan blocks. Edits the title and the inline tasks list. + * Each task gets a compact title + status row; richer fields (sources, + * details, output) round-trip on the payload but aren't editable inline — + * use a standalone `task_card` block for those. + * @param props - editor props + * @param props.block - the plan block to edit + * @param props.onChange - called with the updated block payload + * @returns the rendered plan editor form + */ +export function PlanEditor({ block, onChange }: BlockEditorProps) { + const titleText = typeof block.title === 'string' ? block.title : (block.title?.text ?? ''); + const tasks = block.tasks ?? []; + + const updateTask = (idx: number, change: Partial) => { + onChange({ + ...block, + tasks: tasks.map((t, i) => (i === idx ? { ...t, ...change } : t)) + }); + }; + const removeTask = (idx: number) => { + const next = tasks.filter((_, i) => i !== idx); + onChange({ ...block, tasks: next.length > 0 ? next : undefined }); + }; + const addTask = () => { + onChange({ + ...block, + tasks: [ + ...tasks, + { + type: 'task_card', + task_id: `task_${nanoid(6)}`, + title: 'New step', + status: 'pending' + } + ] + }); + }; + + return ( +
+ + onChange({ ...block, title: e.target.value })} + /> + + +
+ Tasks + {tasks.length === 0 ? ( +

+ No tasks. Add one to render a checklist beneath the title. +

+ ) : null} + {tasks.map((task, idx) => { + const status: TaskCardStatus = task.status ?? 'pending'; + return ( +
+
+ Task {idx + 1} + +
+ + updateTask(idx, { title: e.target.value })} + /> + + + updateTask(idx, { status: v as TaskCardStatus })} + className="flex flex-row flex-wrap gap-3" + > + {STATUSES.map((s) => ( +
+ + +
+ ))} +
+
+
+ ); + })} + +
+
+ ); +} diff --git a/src/components/editors/task-card-editor.tsx b/src/components/editors/task-card-editor.tsx new file mode 100644 index 0000000..30abfab --- /dev/null +++ b/src/components/editors/task-card-editor.tsx @@ -0,0 +1,141 @@ +import { Plus, Trash2 } from 'lucide-react'; +import { Button } from '../../lib/ui/button'; +import { Input } from '../../lib/ui/input'; +import { Label } from '../../lib/ui/label'; +import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group'; +import type { TaskCardBlock, TaskCardStatus, UrlSourceElement } from '../../types'; +import { EditorField } from './field'; +import type { BlockEditorProps } from './types'; + +const STATUSES: readonly TaskCardStatus[] = ['pending', 'in_progress', 'complete', 'error'] as const; + +/** + * Editor form for task_card blocks. Edits the task id, title, status, and + * the cited sources list. The rich-text `details` and `output` fields + * round-trip through the payload but are not editable in the visual + * builder — palette variants that include them keep them on save. + * @param props - editor props + * @param props.block - the task_card block to edit + * @param props.onChange - called with the updated block payload + * @returns the rendered task_card editor form + */ +export function TaskCardEditor({ block, onChange }: BlockEditorProps) { + const status: TaskCardStatus = block.status ?? 'pending'; + return ( +
+ + onChange({ ...block, title: e.target.value })} + /> + + + + onChange({ ...block, task_id: e.target.value })} + /> + + + + onChange({ ...block, status: v as TaskCardStatus })} + className="flex flex-row flex-wrap gap-3" + > + {STATUSES.map((s) => ( +
+ + +
+ ))} +
+
+ + onChange({ ...block, sources: next.length > 0 ? next : undefined })} + /> +
+ ); +} + +/** + * Sub-editor for the task card's `sources` list. Each source is a + * `{ text, url }` pair Slack renders as a labeled link beneath the card. + * @param props - field props + * @param props.sources - the current sources list + * @param props.onChange - called with the updated sources list + * @returns the rendered sources editor + */ +function SourcesField({ + sources, + onChange +}: { + sources: UrlSourceElement[]; + onChange: (next: UrlSourceElement[]) => void; +}) { + const update = (idx: number, change: Partial) => { + onChange(sources.map((s, i) => (i === idx ? { ...s, ...change } : s))); + }; + const removeAt = (idx: number) => onChange(sources.filter((_, i) => i !== idx)); + const addSource = () => { + onChange([...sources, { type: 'url', url: '', text: '' }]); + }; + + return ( +
+ Sources + {sources.length === 0 ? ( +

+ No sources. Add one to cite a document or link beneath the card. +

+ ) : null} + {sources.map((src, idx) => ( +
+
+ Source {idx + 1} + +
+ + update(idx, { text: e.target.value })} + /> + + + update(idx, { url: e.target.value })} + /> + +
+ ))} + +
+ ); +} diff --git a/src/components/editors/video-editor.tsx b/src/components/editors/video-editor.tsx new file mode 100644 index 0000000..eefa0bd --- /dev/null +++ b/src/components/editors/video-editor.tsx @@ -0,0 +1,137 @@ +import { Input } from '../../lib/ui/input'; +import { Textarea } from '../../lib/ui/textarea'; +import type { VideoBlock } from '../../types'; +import { EditorField } from './field'; +import type { BlockEditorProps } from './types'; + +/** + * Editor form for video blocks. Edits the title, alt text, thumbnail/video + * URLs, and optional provider/author/description metadata. Sending a video + * block from Slack requires the `links.embed:write` OAuth scope and a + * `video_url` inside the app's configured unfurl domains — the builder + * does not enforce that here; it just edits the payload. + * @param props - editor props + * @param props.block - the video block to edit + * @param props.onChange - called with the updated block payload + * @returns the rendered video editor form + */ +export function VideoEditor({ block, onChange }: BlockEditorProps) { + return ( +
+ + + onChange({ + ...block, + title: { type: 'plain_text', text: e.target.value, emoji: true } + }) + } + /> + + + + onChange({ ...block, alt_text: e.target.value })} + /> + + + + onChange({ ...block, video_url: e.target.value })} + /> + + + + onChange({ ...block, thumbnail_url: e.target.value })} + /> + + + + onChange({ ...block, title_url: e.target.value || undefined })} + /> + + + +