# Meeting Assistant

Upload meeting audio or paste a transcript, then get a short summary and a checklist of action items with priorities. Tick items off as you go, track progress, and export everything to a text file. Runs entirely in your browser; nothing downloads until you process a meeting.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/meeting-assistant
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/meeting-assistant.json

```tsx
'use client';

/**
 * @file meeting-assistant.tsx
 * @description Meeting Assistant — audio upload OR pasted transcript → Transcribe → Summarize (DistilBART) → Extract action items (Granite 4.0 350M) with a three-step indicator, cancel-preserves-completed-steps, priority/toggle/progress, and a dated .txt export; owns its STT selector + adapter-backed device probe.
 */

import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import {
  downloadBlob,
  useGenerateObject,
  useModelLoad,
  useSummarize,
  useTranscribe,
  type UseModelLoadReturn,
} from '@localmode/react';
import { summarize, transcribe, isWebGPUSupported } from '@localmode/core';
import type {
  LanguageModel,
  ObjectSchema,
  SpeechToTextModel,
  SummarizationModel,
} from '@localmode/core';
import { transformers, isModelCached } from '@localmode/transformers';

import { FileDropzone } from '@/components/file-dropzone';
import { AudioScrubPlayer } from '@/components/audio-scrub-player';
import { ModelSelector } from '@/components/model-selector';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { ErrorAlert } from '@/components/error-alert';
import { cn } from '@/lib/utils';


interface SttModelEntry {
  id: string;
  name: string;
  size: string;
  timestamps: boolean;
}

const STT_MODELS: readonly SttModelEntry[] = [
  { id: 'Xenova/whisper-tiny.en', name: 'Whisper Tiny EN', size: '~40MB', timestamps: true },
  { id: 'onnx-community/moonshine-tiny-ONNX', name: 'Moonshine Tiny', size: '~50MB', timestamps: false },
  { id: 'onnx-community/moonshine-base-ONNX', name: 'Moonshine Base', size: '~237MB', timestamps: false },
];

const DEFAULT_STT_MODEL_ID = STT_MODELS[0].id;

function sttSupportsTimestamps(modelId: string): boolean {
  return STT_MODELS.find((m) => m.id === modelId)?.timestamps ?? false;
}

const SUMMARIZER_MODEL_ID = 'Xenova/distilbart-cnn-6-6';
const SUMMARIZER_MODEL_SIZE = '~200MB';

const SUMMARY_MAX_LENGTH = 200;
const SUMMARY_MIN_LENGTH = 60;

const PLANNER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';
const PLANNER_MODEL_SIZE = '~120MB';

const NO_SPEECH_TEXT = '[No speech detected]';

const ACCEPTED_AUDIO_MIME_TYPES = [
  'audio/wav',
  'audio/x-wav',
  'audio/wave',
  'audio/mp3',
  'audio/mpeg',
  'audio/webm',
  'audio/ogg',
  'audio/mp4',
  'audio/x-m4a',
  'audio/m4a',
  'audio/aac',
  'video/mp4',
];

interface ExtractedActionItem {
  text: string;
  priority: 'high' | 'medium' | 'low';
}

const PRIORITIES = new Set(['high', 'medium', 'low']);

const ACTION_ITEMS_SCHEMA: ObjectSchema<{ items: ExtractedActionItem[] }> = {
  description: 'Action items extracted from a meeting transcript',
  jsonSchema: {
    type: 'object',
    properties: {
      items: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            text: { type: 'string', description: 'The task or commitment' },
            priority: { type: 'string', enum: ['high', 'medium', 'low'] },
          },
          required: ['text', 'priority'],
        },
      },
    },
    required: ['items'],
  },
  parse: (value: unknown) => {
    if (typeof value !== 'object' || value === null || !Array.isArray((value as { items?: unknown }).items)) {
      throw new Error('Expected an object with an "items" array');
    }
    const items: ExtractedActionItem[] = [];
    for (const raw of (value as { items: unknown[] }).items) {
      if (typeof raw !== 'object' || raw === null) continue;
      const text = (raw as { text?: unknown }).text;
      if (typeof text !== 'string' || text.trim().length === 0) continue;
      const rawPriority = String((raw as { priority?: unknown }).priority ?? '').toLowerCase();
      items.push({
        text: text.trim(),
        priority: (PRIORITIES.has(rawPriority) ? rawPriority : 'medium') as ExtractedActionItem['priority'],
      });
    }
    return { items: items.slice(0, 12) };
  },
};

function buildActionItemsPrompt(transcript: string): string {
  return [
    'Extract the action items from this meeting transcript. An action item is a specific task, commitment, or follow-up that someone agreed to do.',
    'For each item provide the task text and a priority: "high" for urgent or deadline-bound items, "medium" for important items, "low" for nice-to-haves.',
    'Return at most 8 items. If there are none, return an empty items list.',
    '',
    'Transcript:',
    '"""',
    transcript,
    '"""',
  ].join('\n');
}

interface MeetingActionItem extends ExtractedActionItem {
  id: string;
  completed: boolean;
}

interface MeetingData {
  sourceLabel: string;
  audio: Blob | null;
  transcript: string;
  summary: string | null;
  actionItems: MeetingActionItem[] | null;
  noSpeech: boolean;
}

function countWords(text: string): number {
  return text.trim().split(/\s+/).filter(Boolean).length;
}

function buildMeetingExport(
  transcript: string,
  summary: string | null,
  actionItems: Array<{ text: string; completed: boolean; priority: string }>,
): string {
  const lines: string[] = ['MEETING TRANSCRIPT', '==================', '', transcript];

  if (summary) {
    lines.push('', 'SUMMARY', '-------', summary);
  }

  if (actionItems.length > 0) {
    lines.push('', 'ACTION ITEMS', '------------');
    actionItems.forEach((item, i) => {
      const status = item.completed ? '[x]' : '[ ]';
      lines.push(`${status} ${i + 1}. ${item.text} (${item.priority})`);
    });
  }

  return lines.join('\n');
}

function meetingExportFilename(now = new Date()): string {
  return `meeting-transcript-${now.toISOString().slice(0, 10)}.txt`;
}


type MeetingStep = 'transcribe' | 'summarize' | 'extract';
type StepState = 'pending' | 'active' | 'done' | 'skipped';

const STEPS: ReadonlyArray<{ id: MeetingStep; label: string }> = [
  { id: 'transcribe', label: 'Transcribe' },
  { id: 'summarize', label: 'Summarize' },
  { id: 'extract', label: 'Extract' },
];

const PRIORITY_BADGE: Record<ExtractedActionItem['priority'], string> = {
  high: 'bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/20',
  medium: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 ring-1 ring-inset ring-amber-500/20',
  low: 'bg-muted text-muted-foreground ring-1 ring-inset ring-border',
};

export function MeetingAssistantBlock() {
  const [sttModelId, setSttModelId] = useState(DEFAULT_STT_MODEL_ID);
  const [meeting, setMeeting] = useState<MeetingData | null>(null);

  const [device, setDevice] = useState<'webgpu' | 'wasm' | null>(null);
  useEffect(() => {
    let alive = true;
    void isWebGPUSupported().then((ok) => {
      if (alive) setDevice(ok ? 'webgpu' : 'wasm');
    });
    return () => {
      alive = false;
    };
  }, []);

  return (
    <div className="flex flex-col gap-4 p-4">
      {device ? (
        <MeetingSession
          key={sttModelId}
          sttModelId={sttModelId}
          onSttModelIdChange={setSttModelId}
          device={device}
          meeting={meeting}
          onMeetingChange={setMeeting}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </div>
  );
}

interface MeetingSessionProps {
  sttModelId: string;
  onSttModelIdChange: (id: string) => void;
  device: 'webgpu' | 'wasm';
  meeting: MeetingData | null;
  onMeetingChange: Dispatch<SetStateAction<MeetingData | null>>;
}

function MeetingSession({
  sttModelId,
  onSttModelIdChange,
  device,
  meeting,
  onMeetingChange,
}: MeetingSessionProps) {
  const stt = useModelLoad<SpeechToTextModel>({
    key: `meeting-assistant-stt:${sttModelId}`,
    create: (onProgress) =>
      transformers.speechToText(sttModelId, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => transcribe({ model, audio: new Float32Array(16_000) }),
    isCached: () => isModelCached(sttModelId),
  });

  const sttEntry = STT_MODELS.find((m) => m.id === sttModelId) ?? STT_MODELS[0];

  return (
    <div className="flex flex-col gap-4">
      {}
      <section className="flex flex-col gap-2">
        <div data-model-id={sttModelId}>
          <p className="mb-1 text-xs font-medium text-muted-foreground">
            Speech-to-text model - downloads only when you transcribe a meeting or press its download
            action.
          </p>
          <ModelSelector
            models={STT_MODELS.map((m) => ({
              id: m.id,
              name: m.name,
              backend: 'onnx' as const,
              category: m.timestamps ? 'STT · timestamps' : 'STT',
              size: m.size,
            }))}
            selectedId={sttModelId}
            busyIds={stt.status === 'loading' ? new Set([sttModelId]) : undefined}
            onSelect={onSttModelIdChange}
            onDownload={(id) => {
              if (id === sttModelId) void stt.load().catch(() => {});
              else onSttModelIdChange(id);
            }}
          />
        </div>
        {stt.status === 'loading' && (
          <ModelLoadingPanel
            name={sttEntry.name}
            size={sttEntry.size}
            category="Speech-to-text"
            progress={stt.progressValue}
            cached={stt.cached === true}
          />
        )}
      </section>

      {}
      {stt.model ? (
        <MeetingPipeline
          stt={stt as UseModelLoadReturn<SpeechToTextModel>}
          sttModel={stt.model}
          sttModelId={sttModelId}
          device={device}
          meeting={meeting}
          onMeetingChange={onMeetingChange}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </div>
  );
}

interface MeetingPipelineProps {
  stt: UseModelLoadReturn<SpeechToTextModel>;
  sttModel: SpeechToTextModel;
  sttModelId: string;
  device: 'webgpu' | 'wasm';
  meeting: MeetingData | null;
  onMeetingChange: Dispatch<SetStateAction<MeetingData | null>>;
}

function MeetingPipeline({
  stt,
  sttModel,
  sttModelId,
  device,
  meeting,
  onMeetingChange,
}: MeetingPipelineProps) {
  const transcriber = useTranscribe({ model: sttModel });
  const summarizerLoad = useModelLoad<SummarizationModel>({
    key: `meeting-assistant-summarizer:${SUMMARIZER_MODEL_ID}`,
    create: (onProgress) =>
      transformers.summarizer(SUMMARIZER_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) =>
      summarize({
        model,
        text: 'The team met to plan the release. The release ships next week after testing completes.',
        maxLength: 20,
        minLength: 5,
      }),
    isCached: () => isModelCached(SUMMARIZER_MODEL_ID),
  });
  const plannerLoad = useModelLoad<LanguageModel>({
    key: `meeting-assistant-planner:${PLANNER_MODEL_ID}`,
    create: (onProgress) =>
      transformers.languageModel(PLANNER_MODEL_ID, {
        device,
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isCached: () => isModelCached(PLANNER_MODEL_ID),
  });

  const summarizerModel = summarizerLoad.model;
  const plannerModel = plannerLoad.model;
  if (!summarizerModel || !plannerModel) {
    return <p className="p-4 text-sm text-muted-foreground">Preparing…</p>;
  }
  return (
    <MeetingSurface
      stt={stt}
      transcriber={transcriber}
      summarizerLoad={summarizerLoad}
      summarizerModel={summarizerModel}
      plannerLoad={plannerLoad}
      plannerModel={plannerModel}
      sttModelId={sttModelId}
      meeting={meeting}
      onMeetingChange={onMeetingChange}
    />
  );
}

interface MeetingSurfaceProps {
  stt: UseModelLoadReturn<SpeechToTextModel>;
  transcriber: ReturnType<typeof useTranscribe>;
  summarizerLoad: UseModelLoadReturn<SummarizationModel>;
  summarizerModel: SummarizationModel;
  plannerLoad: UseModelLoadReturn<LanguageModel>;
  plannerModel: LanguageModel;
  sttModelId: string;
  meeting: MeetingData | null;
  onMeetingChange: Dispatch<SetStateAction<MeetingData | null>>;
}

function MeetingSurface({
  stt,
  transcriber,
  summarizerLoad,
  summarizerModel,
  plannerLoad,
  plannerModel,
  sttModelId,
  meeting,
  onMeetingChange,
}: MeetingSurfaceProps) {
  const summarizer = useSummarize({ model: summarizerModel });
  const extractor = useGenerateObject({
    model: plannerModel,
    schema: ACTION_ITEMS_SCHEMA,
    temperature: 0,
    maxTokens: 512,
  });

  const [pastedText, setPastedText] = useState('');
  const [activeStep, setActiveStep] = useState<MeetingStep | null>(null);
  const [doneSteps, setDoneSteps] = useState<ReadonlySet<MeetingStep>>(new Set());
  const [transcribeSkipped, setTranscribeSkipped] = useState(false);
  const [actionError, setActionError] = useState<string | null>(null);
  const [dismissedError, setDismissedError] = useState<string | null>(null);

  const runIdRef = useRef(0);

  const running = activeStep !== null;

  const markDone = (step: MeetingStep) =>
    setDoneSteps((prev) => {
      const next = new Set(prev);
      next.add(step);
      return next;
    });

  const runPipeline = async (source: { file?: File; text?: string }) => {
    const runId = ++runIdRef.current;
    const cancelled = () => runIdRef.current !== runId;

    setActionError(null);
    setDismissedError(null);
    setDoneSteps(new Set());
    setTranscribeSkipped(!!source.text);
    transcriber.reset();
    summarizer.reset();
    extractor.reset();

    const base: MeetingData = {
      sourceLabel: source.file ? source.file.name : 'Pasted transcript',
      audio: source.file ?? null,
      transcript: '',
      summary: null,
      actionItems: null,
      noSpeech: false,
    };
    onMeetingChange(base);

    try {
      let transcript = source.text?.trim() ?? '';
      if (source.file) {
        setActiveStep('transcribe');
        await stt.load();
        if (cancelled()) return;
        const result = await transcriber.execute(source.file);
        if (!result || cancelled()) return;
        transcript = result.text.trim();
        if (!transcript) {
          onMeetingChange({ ...base, transcript: NO_SPEECH_TEXT, noSpeech: true });
          markDone('transcribe');
          return;
        }
        markDone('transcribe');
      }
      onMeetingChange((prev) => (prev ? { ...prev, transcript } : prev));

      setActiveStep('summarize');
      await summarizerLoad.load();
      if (cancelled()) return;
      const summaryResult = await summarizer.execute({
        text: transcript,
        maxLength: SUMMARY_MAX_LENGTH,
        minLength: SUMMARY_MIN_LENGTH,
      });
      if (!summaryResult || cancelled()) return;
      onMeetingChange((prev) => (prev ? { ...prev, summary: summaryResult.summary } : prev));
      markDone('summarize');

      setActiveStep('extract');
      await plannerLoad.load();
      if (cancelled()) return;
      const extraction = await extractor.execute(buildActionItemsPrompt(transcript));
      if (!extraction || cancelled()) return;
      onMeetingChange((prev) =>
        prev
          ? {
              ...prev,
              actionItems: extraction.object.items.map((item) => ({
                ...item,
                id: crypto.randomUUID(),
                completed: false,
              })),
            }
          : prev,
      );
      markDone('extract');
    } catch (err) {
      if (!cancelled()) setActionError(err instanceof Error ? err.message : String(err));
    } finally {
      if (runIdRef.current === runId) setActiveStep(null);
    }
  };

  const cancelPipeline = () => {
    runIdRef.current++;
    transcriber.cancel();
    summarizer.cancel();
    extractor.cancel();
    setActiveStep(null);
  };

  const resetMeeting = () => {
    cancelPipeline();
    onMeetingChange(null);
    setPastedText('');
    setDoneSteps(new Set());
    setTranscribeSkipped(false);
    setActionError(null);
  };

  const toggleActionItem = (id: string) => {
    onMeetingChange((prev) =>
      prev && prev.actionItems
        ? {
            ...prev,
            actionItems: prev.actionItems.map((item) =>
              item.id === id ? { ...item, completed: !item.completed } : item,
            ),
          }
        : prev,
    );
  };

  const exportMeeting = () => {
    if (!meeting) return;
    downloadBlob(
      buildMeetingExport(meeting.transcript, meeting.summary, meeting.actionItems ?? []),
      meetingExportFilename(),
    );
  };

  const stepState = (step: MeetingStep): StepState => {
    if (step === 'transcribe' && transcribeSkipped) return 'skipped';
    if (doneSteps.has(step)) return 'done';
    if (activeStep === step) return 'active';
    return 'pending';
  };

  const rawErrorText =
    actionError ??
    transcriber.error?.message ??
    summarizer.error?.message ??
    extractor.error?.message ??
    null;
  const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null;

  const statusText = running
    ? activeStep === 'transcribe'
      ? 'transcribing'
      : activeStep === 'summarize'
        ? 'summarizing'
        : 'extracting'
    : errorText
      ? 'error'
      : meeting
        ? 'ready'
        : 'idle';
  const statusLabel =
    statusText === 'transcribing'
      ? 'Transcribing…'
      : statusText === 'summarizing'
        ? 'Summarizing…'
        : statusText === 'extracting'
          ? 'Extracting…'
          : statusText === 'error'
            ? 'Error'
            : statusText === 'ready'
              ? 'Ready'
              : 'Idle';

  const doneCount = meeting?.actionItems?.filter((i) => i.completed).length ?? 0;
  const totalCount = meeting?.actionItems?.length ?? 0;

  return (
    <div className="flex flex-col gap-4">
      <p
        data-status={statusText}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusLabel}
      </p>
      {errorText && (
        <ErrorAlert
          message={errorText}
          onDismiss={() => {
            setDismissedError(rawErrorText);
            setActionError(null);
          }}
        />
      )}

      {}
      {!meeting && !running && (
        <div className="grid gap-4 sm:grid-cols-2">
          <section aria-label="Upload meeting audio" className="flex flex-col gap-2 rounded-lg border border-border p-4">
            <h2 className="text-sm font-medium">Upload meeting audio</h2>
            <div>
              <FileDropzone
                onUpload={(files) => {
                  const file = files[0];
                  if (file) void runPipeline({ file });
                }}
                accept={ACCEPTED_AUDIO_MIME_TYPES}
                multiple={false}
                label="Drop a recording or click to browse"
                hint=".mp3, .wav, .webm, .m4a, .mp4"
              />
            </div>
            {sttModelId !== 'onnx-community/moonshine-base-ONNX' && (
              <p className="text-xs text-muted-foreground">
                Tip: Moonshine Base (~237MB) gives the best meeting transcription - pick it in the
                speech-to-text selector above.
              </p>
            )}
          </section>
          <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
            <h2 className="text-sm font-medium">Or paste a transcript</h2>
            <textarea
              value={pastedText}
              onChange={(e) => setPastedText(e.target.value)}
              rows={6}
              placeholder="Paste a meeting transcript - skips the transcription step."
              aria-label="Meeting transcript"
              className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            />
            <button
              type="button"
              onClick={() => void runPipeline({ text: pastedText })}
              disabled={!pastedText.trim()}
              className="inline-flex h-8 w-fit items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
            >
              Process transcript
            </button>
          </section>
        </div>
      )}

      {}
      {(running || meeting) && (
        <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
          {}
          <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <ol aria-label="Meeting pipeline" className="flex flex-wrap items-center gap-2">
              {STEPS.map((step, i) => {
                const state = stepState(step.id);
                return (
                  <li
                    key={step.id}
                    data-step={step.id}
                    data-state={state}
                    aria-label={step.label}
                    className="flex items-center gap-2"
                  >
                    {i > 0 && (
                      <span className="text-muted-foreground" aria-hidden="true">
                        →
                      </span>
                    )}
                    <span
                      className={cn(
                        'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium',
                        state === 'done' && 'bg-primary/10 text-primary',
                        state === 'active' && 'bg-primary text-primary-foreground',
                        state === 'pending' && 'bg-muted text-muted-foreground',
                        state === 'skipped' && 'bg-muted text-muted-foreground line-through',
                      )}
                    >
                      {state === 'active' && (
                        <span
                          className="size-3 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground motion-reduce:animate-none"
                          aria-hidden="true"
                        />
                      )}
                      {state === 'done' && <span aria-hidden="true">✓</span>}
                      {step.label}
                    </span>
                  </li>
                );
              })}
            </ol>
            <div className="flex flex-wrap gap-2">
              {running && (
                <button
                  type="button"
                  onClick={cancelPipeline}
                  className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  Cancel
                </button>
              )}
              <button
                type="button"
                onClick={exportMeeting}
                disabled={!meeting || !meeting.transcript}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Export .txt
              </button>
              <button
                type="button"
                onClick={resetMeeting}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                New meeting
              </button>
            </div>
          </div>

          {activeStep === 'summarize' && summarizerLoad.status === 'loading' && (
            <ModelLoadingPanel
              name="DistilBART CNN 6-6"
              size={SUMMARIZER_MODEL_SIZE}
              category="Summarization"
              progress={summarizerLoad.progressValue}
              cached={summarizerLoad.cached === true}
            />
          )}
          {activeStep === 'extract' && plannerLoad.status === 'loading' && (
            <ModelLoadingPanel
              name="Granite 4.0 350M"
              size={PLANNER_MODEL_SIZE}
              category="Action-item LM"
              progress={plannerLoad.progressValue}
              cached={plannerLoad.cached === true}
            />
          )}
        </section>
      )}

      {}
      {meeting && (
        <div className="flex flex-col gap-4">
          {meeting.audio && (
            <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
              <h2 className="text-sm font-medium">{meeting.sourceLabel}</h2>
              <AudioScrubPlayer audio={meeting.audio} />
            </section>
          )}

          <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
            <div className="flex items-center gap-2">
              <h2 className="text-sm font-medium">Transcript</h2>
              {meeting.transcript && !meeting.noSpeech && (
                <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70">
                  {countWords(meeting.transcript)} words
                </span>
              )}
            </div>
            <p className="whitespace-pre-wrap text-sm">
              {meeting.transcript || (activeStep === 'transcribe' ? 'Transcribing…' : '')}
            </p>
          </section>

          {meeting.summary !== null && (
            <section aria-label="Summary" className="flex flex-col gap-2 rounded-lg border border-border p-4">
              <div className="flex items-center gap-2">
                <h2 className="text-sm font-medium">Summary</h2>
                <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70">
                  {countWords(meeting.summary)} words
                </span>
              </div>
              <p className="whitespace-pre-wrap text-sm">
                {meeting.summary}
              </p>
            </section>
          )}

          {meeting.actionItems !== null && (
            <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
              <div className="flex items-center gap-2">
                <h2 className="text-sm font-medium">Action items</h2>
                <span
                  data-done={doneCount}
                  data-total={totalCount}
                  className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70"
                >
                  {doneCount}/{totalCount} done
                </span>
              </div>
              {meeting.actionItems.length === 0 ? (
                <p className="text-sm text-muted-foreground">No action items found.</p>
              ) : (
                <ul aria-label="Action items" className="flex flex-col gap-1.5">
                  {meeting.actionItems.map((item) => (
                    <li
                      key={item.id}
                      data-priority={item.priority}
                      className="flex items-start gap-2 rounded-md border border-border bg-card p-2"
                    >
                      {}
                      <label className="-m-1 flex size-11 shrink-0 cursor-pointer items-center justify-center">
                        <input
                          type="checkbox"
                          checked={item.completed}
                          onChange={() => toggleActionItem(item.id)}
                          aria-label={`Mark "${item.text}" ${item.completed ? 'incomplete' : 'complete'}`}
                          className="size-4 accent-primary focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                        />
                      </label>
                      <span
                        className={cn(
                          'mt-1.5 flex-1 text-sm',
                          item.completed && 'text-muted-foreground line-through',
                        )}
                      >
                        {item.text}
                      </span>
                      <span
                        className={cn(
                          'rounded-md px-1.5 py-0.5 text-[11px] font-medium capitalize',
                          PRIORITY_BADGE[item.priority],
                        )}
                      >
                        {item.priority}
                      </span>
                    </li>
                  ))}
                </ul>
              )}
            </section>
          )}
        </div>
      )}
    </div>
  );
}
```
