



<Preview className="min-h-[520px]">
  <GenerateTranscriptPickerExample />
</Preview>

This picker mirrors the original create-transcription flow: single audio/video
selection, search, matter filtering, view toggle, upload, provider selection in
the footer, and an async confirm path that locks the dialog while work starts.

```tsx title="components/generate-transcript-picker/index.tsx"
import { useMemo, useState } from "react";
import { FolderOpen } from "@tilt-legal/cubitt-icons/ui/outline";
import {
  Files,
  type FilesItem,
  type FilesUploadEntry,
  type FilesViewMode,
} from "@tilt-legal/cubitt-components/files";
import { Button } from "@tilt-legal/cubitt-components/button";
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@tilt-legal/cubitt-components/dialog";
import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateHeading,
  EmptyStateMedia,
  EmptyStateTitle,
} from "@tilt-legal/cubitt-components/empty-state";
import { Input } from "@tilt-legal/cubitt-components/input";
import { SearchExpand } from "@tilt-legal/cubitt-components/search-expand";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@tilt-legal/cubitt-components/select";
import { Separator } from "@tilt-legal/cubitt-components/separator";
import { toast } from "@tilt-legal/cubitt-components/toast";
import { Toolbar } from "@tilt-legal/cubitt-components/toolbar";
import {
  formatPlural,
  getFileLabel,
} from "@tilt-legal/cubitt-components/utilities/formatters";
import { TranscriptMatterFilter } from "@/components/transcript-matter-filter";
import { TranscriptViewToggle } from "@/components/transcript-view-toggle";
import {
  providerItems,
  transcriptFiles,
  transcriptProviders,
  type TranscriptFileSource,
  type TranscriptMatter,
} from "@/lib/transcript-picker-data";

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function filterFiles({
  items,
  query,
  selectedMatters,
}: {
  items: FilesItem<TranscriptFileSource>[];
  query: string;
  selectedMatters: TranscriptMatter[];
}) {
  const selectedMatterIds = new Set(selectedMatters.map((matter) => matter.id));
  const normalizedQuery = query.trim().toLowerCase();

  return items.filter((item) => {
    const matchesMatter =
      selectedMatterIds.size === 0 ||
      item.source?.matters.some((matter) => selectedMatterIds.has(matter.id));
    const matchesQuery =
      !normalizedQuery ||
      [item.name, item.description, item.sizeLabel, item.source?.owner]
        .filter((value): value is string => Boolean(value))
        .some((value) => value.toLowerCase().includes(normalizedQuery));

    return matchesMatter && matchesQuery;
  });
}

function getFileType(item: FilesItem<TranscriptFileSource>) {
  return item.kind === "file"
    ? getFileLabel(item.name, item.mediaType)
    : "Folder";
}

function TranscriptListHeader() {
  return (
    <div
      aria-hidden="true"
      className="grid h-10 grid-cols-[2rem_minmax(12rem,1fr)_8rem_7rem_8rem] items-center rounded-lg border border-border-3 bg-surface-1 font-medium text-fg-2 text-xs"
    >
      <span />
      <span className="px-1">Name</span>
      <span className="px-3">Type</span>
      <span className="px-3">Size</span>
      <span className="px-3">Updated</span>
    </div>
  );
}

function renderListFiles(items: FilesItem<TranscriptFileSource>[]) {
  return items.map((item) => (
    <Files.Item
      className="grid-cols-[2rem_minmax(12rem,1fr)_8rem_7rem_8rem]"
      item={item}
      key={item.id}
    >
      <div className="flex justify-center">
        <Files.ItemIcon />
      </div>
      <div className="flex min-w-0 items-center gap-1.5 py-2 ps-1 pe-3">
        <Files.ItemName />
      </div>
      <span className="truncate px-3 py-2 text-fg-2">{getFileType(item)}</span>
      <span className="truncate px-3 py-2 text-fg-2">{item.sizeLabel}</span>
      <span className="truncate px-3 py-2 text-fg-2">
        {item.source?.updatedLabel}
      </span>
    </Files.Item>
  ));
}

function TranscriptUploadControl({ disabled }: { disabled?: boolean }) {
  return (
    <div className="flex items-center gap-3 pl-1">
      <Separator className="h-6!" orientation="vertical" />
      <Files.UploadButton
        accept={["audio/*", "video/*"]}
        disabled={disabled}
        onFilesAccepted={(entries: FilesUploadEntry[]) => {
          toast.info("Upload selected", {
            description: formatPlural(entries.length, "file"),
          });
        }}
      />
    </div>
  );
}

export function GenerateTranscriptPicker() {
  const [open, setOpen] = useState(false);
  const [provider, setProvider] = useState("deepgram");
  const [query, setQuery] = useState("");
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [selectedMatters, setSelectedMatters] = useState<TranscriptMatter[]>(
    [],
  );
  const [view, setView] = useState<FilesViewMode>("list");
  const [pending, setPending] = useState(false);
  const visibleFiles = useMemo(
    () => filterFiles({ items: transcriptFiles, query, selectedMatters }),
    [query, selectedMatters],
  );

  function closePicker() {
    setOpen(false);
    setQuery("");
    setSelectedIds([]);
    setSelectedMatters([]);
    setView("list");
    setPending(false);
  }

  async function handleCreate() {
    if (selectedIds.length === 0 || pending) return;

    setPending(true);
    await sleep(1000);
    toast.success("Transcript generation started", {
      description: `${formatPlural(selectedIds.length, "file")}, ${provider}`,
    });
    closePicker();
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(nextOpen) => {
        if (nextOpen) setOpen(true);
        else closePicker();
      }}
    >
      <DialogTrigger render={<Button variant="secondary" />}>
        Generate Transcript
      </DialogTrigger>
      <DialogContent
        className="w-full sm:h-137 sm:max-w-4xl"
        showDismissButton={!pending}
      >
        <DialogHeader className="flex-row items-center justify-between py-3.5">
          <DialogTitle className="shrink-0 whitespace-nowrap">
            Select file
          </DialogTitle>
          <Toolbar
            className="w-auto shrink-0 justify-end"
            data-files-selection-preserve="true"
          >
            <SearchExpand
              collapseOnBlurWhenEmpty
              expandedWidth="14rem"
              label="Search files"
            >
              <Input
                aria-label="Search files"
                disabled={pending}
                onChange={(event) => setQuery(event.currentTarget.value)}
                placeholder="Search files"
                value={query}
              />
            </SearchExpand>
            <TranscriptMatterFilter
              disabled={pending}
              onValueChange={setSelectedMatters}
              value={selectedMatters}
            />
            <TranscriptViewToggle
              disabled={pending}
              onValueChange={setView}
              value={view}
            />
            <TranscriptUploadControl disabled={pending} />
          </Toolbar>
        </DialogHeader>
        <DialogBody className="pt-.5 [&_[data-slot=table-header]]:hidden [&_tbody[aria-hidden]]:hidden">
          <Files
            disabled={pending}
            onSelectedIdsChange={setSelectedIds}
            selectedIds={selectedIds}
            canSelect={(item) => item.kind !== "folder"}
            selectionBehavior="toggle"
            selectionMode="single"
          >
            <Files.Content className="min-h-0 flex-1 overflow-auto">
              {visibleFiles.length === 0 ? (
                <div className="flex min-h-72 items-center justify-center">
                  <EmptyState variant="ghost">
                    <EmptyStateMedia>
                      <FolderOpen />
                    </EmptyStateMedia>
                    <EmptyStateHeading>
                      <EmptyStateTitle>No audio or video found</EmptyStateTitle>
                      <EmptyStateDescription>
                        Try a different search or matter filter.
                      </EmptyStateDescription>
                    </EmptyStateHeading>
                  </EmptyState>
                </div>
              ) : view === "grid" ? (
                <Files.Grid>
                  {visibleFiles.map((item) => (
                    <Files.Item item={item} key={item.id} />
                  ))}
                </Files.Grid>
              ) : (
                <Files.List header={<TranscriptListHeader />}>
                  {renderListFiles(visibleFiles)}
                </Files.List>
              )}
            </Files.Content>
          </Files>
        </DialogBody>
        <DialogFooter className="items-center sm:justify-between">
          <Select
            items={providerItems}
            onValueChange={(nextValue) => {
              if (typeof nextValue === "string") setProvider(nextValue);
            }}
            value={provider}
          >
            <SelectTrigger className="w-40">
              <SelectValue placeholder="Provider" />
            </SelectTrigger>
            <SelectContent>
              {transcriptProviders.map((transcriptProvider) => (
                <SelectItem
                  key={transcriptProvider.id}
                  value={transcriptProvider.id}
                >
                  {transcriptProvider.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <div className="flex items-center gap-1">
            <DialogClose disabled={pending}>Cancel</DialogClose>
            <Button
              disabled={selectedIds.length === 0 || pending}
              onClick={handleCreate}
              pending={pending}
            >
              Create
            </Button>
          </div>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

```tsx title="components/generate-transcript-picker/matter-filter.tsx"
import { BarsFilter } from "@tilt-legal/cubitt-icons/ui/outline";
import { Button } from "@tilt-legal/cubitt-components/button";
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxItemIndicator,
  ComboboxList,
  ComboboxSeparator,
  ComboboxTrigger,
} from "@tilt-legal/cubitt-components/combobox";
import {
  transcriptMatters,
  type TranscriptMatter,
} from "@/lib/transcript-picker-data";

export function TranscriptMatterFilter({
  disabled,
  onValueChange,
  value,
}: {
  disabled?: boolean;
  onValueChange: (matters: TranscriptMatter[]) => void;
  value: TranscriptMatter[];
}) {
  return (
    <Combobox
      items={transcriptMatters}
      multiple
      onValueChange={(nextValue) => {
        onValueChange(Array.isArray(nextValue) ? nextValue : []);
      }}
      value={value}
    >
      <ComboboxTrigger
        render={<Button disabled={disabled} mode="icon" variant="secondary" />}
      >
        <BarsFilter />
      </ComboboxTrigger>
      <ComboboxContent align="end">
        <ComboboxInput placeholder="Search matters..." />
        <ComboboxSeparator />
        <ComboboxEmpty>No matters found.</ComboboxEmpty>
        <ComboboxList>
          {(matter: TranscriptMatter) => (
            <ComboboxItem key={matter.id} value={matter}>
              <ComboboxItemIndicator />
              {matter.value}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}
```

```tsx title="components/generate-transcript-picker/view-toggle.tsx"
import { Grid, Menu as MenuIcon } from "@tilt-legal/cubitt-icons/ui/outline";
import type { FilesViewMode } from "@tilt-legal/cubitt-components/files";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@tilt-legal/cubitt-components/toggle-group";

export function TranscriptViewToggle({
  disabled,
  onValueChange,
  value,
}: {
  disabled?: boolean;
  onValueChange: (value: FilesViewMode) => void;
  value: FilesViewMode;
}) {
  return (
    <ToggleGroup
      aria-label="View options"
      disabled={disabled}
      groupVariant="segmented"
      multiple={false}
      onValueChange={(nextValue) => {
        if (nextValue === "grid" || nextValue === "list") {
          onValueChange(nextValue);
        }
      }}
      value={value}
    >
      <ToggleGroupItem aria-label="Grid view" mode="icon" value="grid">
        <Grid />
      </ToggleGroupItem>
      <ToggleGroupItem aria-label="List view" mode="icon" value="list">
        <MenuIcon />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}
```

```typescript title="components/generate-transcript-picker/data.ts"
import type { FilesItem } from "@tilt-legal/cubitt-components/files";
import { formatBytes } from "@tilt-legal/cubitt-components/utilities/formatters";

export type TranscriptMatter = { id: string; value: string };

export type TranscriptFileSource = {
  matters: TranscriptMatter[];
  owner: string;
  size: number;
  updatedLabel: string;
};

export const transcriptMatters = [
  { id: "m1", value: "Smith v. Anderson" },
  { id: "m3", value: "Tech Corp Acquisition" },
] satisfies TranscriptMatter[];

export const transcriptProviders = [
  { id: "deepgram", label: "Deepgram" },
  { id: "elevenlabs", label: "ElevenLabs" },
  { id: "azure", label: "Azure Speech" },
] as const;

export const providerItems = transcriptProviders.map((provider) => ({
  label: provider.label,
  value: provider.id,
}));

export const transcriptFiles: FilesItem<TranscriptFileSource>[] = [
  {
    id: "deposition-recording",
    kind: "file",
    name: "Deposition Recording.mp4",
    mediaType: "video/mp4",
    sizeLabel: formatBytes(145_000_000),
    description: "Sarah Chen",
    source: {
      owner: "Sarah Chen",
      size: 145_000_000,
      updatedLabel: "Jan 10",
      matters: [transcriptMatters[0]!],
    },
  },
  {
    id: "client-call",
    kind: "file",
    name: "Client Call - Jan 8.mp3",
    mediaType: "audio/mpeg",
    sizeLabel: formatBytes(12_400_000),
    description: "Michael Torres",
    source: {
      owner: "Michael Torres",
      size: 12_400_000,
      updatedLabel: "Jan 8",
      matters: [transcriptMatters[0]!],
    },
  },
];
```
