AI

Chat Import

A staged importer for assistant exports — reads ChatGPT, Claude and JSON-Lines files, previews the conversations it recovered with per-item checkboxes, and ends on a summary that names every skipped item and why.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowLeft,
  Ban,
  Check,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  FileJson,
  LoaderCircle,
  MessagesSquare,
  Minus,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chat-import.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ChatImport" component (lucide-react icons,
a shadcn Button / Badge / Input, and a drag-and-drop file picker) that walks a
user through moving conversations out of another assistant and into this app.
The card owns one linear flow and shows exactly one stage at a time.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>.
- onImport: (conversation, signal: AbortSignal) => void | Promise<void>
  — called once per selected conversation, in list order, awaited before the
  next one starts. A rejection becomes a per-item failure, never a dead flow.
- onComplete?: (summary) => void — fires once when a run settles (finished or
  canceled) and never after unmount.
- parse?: (text, fileName?) => Parsed | Promise<Parsed> — replaces the built-in
  reader; async so it can run in a worker or on your server.
- initialFile?: { name?: string; text: string } — start from text you already
  have instead of the picker.
- autoStart?: boolean (default false) — skip review and import everything.
- accept?: string (default ".json,.jsonl,.txt,application/json,text/plain"),
  maxSize?: number — forwarded to the picker.
- heading?: string (default "Import conversations").
- Types: Conversation { id, title, messages: { role, text, at? }[], createdAt?,
  updatedAt? }; Skip { label, reason, detail? } with reason ∈ empty |
  unsupported | malformed | duplicate | deselected | failed | canceled;
  Parsed { vendor, conversations, skipped }; Summary { imported, skipped,
  canceled }. Stage ∈ pick | parsing | review | importing | done.
- Export the parser itself (parseChatExport) so a consumer can reuse it
  server-side or unit-test it without mounting the card.

Behavior — reading a foreign file
- Nothing is asserted. Every field comes out of `unknown` through small readers
  that return a value or undefined, so one broken entry can never throw the
  other four hundred away.
- Detect the container: a bare array, or an object with conversations / chats /
  threads / sessions / history / data / items, or a single exported thread.
  If JSON.parse fails, fall back to JSON Lines — one conversation per line, and
  a malformed line costs only that line (reported as "Line 12").
- Detect the dialect per item, not per file: `mapping` object → ChatGPT node
  graph; `chat_messages` array → Claude; `messages`/`turns`/`history` array →
  generic. An item with none of those is skipped as "unsupported". A file that
  mixes dialects reports vendor "mixed".
- ChatGPT stores a graph with regeneration branches. Collect every node that
  carries a message and order by create_time (nodes with no stamp keep object
  order and sort last) — walking `current_node` upwards silently drops branches
  the user actually read. Drop nodes flagged
  metadata.is_visually_hidden_from_conversation: those turns were never on
  screen in the source product either.
- Message bodies come as a string, { parts: [...] }, [{ type: "text", text }],
  or a mix. Non-text parts (image pointers, tool payloads) collapse to "" and
  drop out, so an image-only turn reads as empty instead of "[object Object]".
- Timestamps arrive as float seconds, milliseconds or ISO strings. Use 1e11 as
  the seconds/ms discriminator (as ms it is 1973, as seconds it is the year
  5138). Conversation stamps fall back to the first / last turn.
- An unrecognised speaker becomes a system note — never guess "user" or
  "assistant"; the review step exists so the user sees what will be written.
- Titles: explicit title/name/subject, else the first user turn collapsed to one
  line and cut at ~64 chars, else "Conversation 7". A bare [{role, content}]
  array is ONE conversation and takes its name from the file name.
- Skip, with the reason kept: empty (no readable turn), duplicate (same id, or
  same title + length + opening turn), unsupported, malformed. The parser only
  throws when the FILE is unusable (empty, not JSON, no list anywhere) — that
  message goes back to the picker in an inline alert, not to a toast.

Behavior — the stage machine
- pick → parsing → review → importing → done. Back exists on parsing and review;
  once the first write is issued there is no back, only Cancel.
- Two entry points into parsing: an event handler (picker / paste) sets the
  stage synchronously so the spinner appears on that click, and an initialFile
  effect that only schedules the async read — the effect itself sets no state.
- Stale-parse token: every parse claims an incrementing token, and both await
  points bail unless the token is still theirs. A second file dropped while the
  first is still parsing wins; the older parse resolves into nothing. Back and
  reset bump the token too, which is how Cancel works during parsing.
- review lists one row per conversation: a checkbox-role button covering the
  whole row (title, turn count, date range, first prompt), all selected by
  default. A tri-state header toggle drives the rows the user can SEE — with a
  filter active it must not silently flip hidden rows. Show a filter box only
  past ~6 conversations, and say how many rows the filter is hiding.
- Parser skips are shown on review behind a disclosure ("4 items skipped while
  reading"), so the count is never hidden but the list never dominates.
- Import runs one conversation at a time so progress means something: set
  progress before the await, await onImport, then record ok / failed. A second
  Import click is REFUSED by a ref claimed synchronously on the click that starts
  the run — restarting would hand the consumer a conversation that is already in
  flight a second time, and no summary can take that write back. A new file is
  what supersedes a run: it bumps a run token and aborts the old controller, and
  the older loop discards everything it collected the moment it notices.
- Cancel aborts an AbortController. Race the consumer's promise against the
  abort by hand (not Promise.race) so a consumer that ignores the signal cannot
  keep the UI hostage: the current item and every item after it are reported as
  "canceled", never as imported — only the consumer knows if that write landed.
- done is a summary, not a toast: imported count, messages written, and every
  skip grouped by reason (failures first, user choices last) with up to 5 labels
  per group and "and N more" after that. "Import another file" resets to pick.
- Unmount aborts the controller and flips a mounted ref; no state update, and no
  onComplete, ever happens afterwards. The queued initialFile read is cleared in
  the effect cleanup.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground on the shell, bg-muted
  for the progress track, bg-primary + text-primary-foreground for the filled
  checkbox, step dots and progress fill, text-muted-foreground for meta lines,
  border / border-dashed for panels, text-destructive with
  border-destructive/30 + bg-destructive/5 for the read error, ring for focus.
  No hex, no rgb(), no oklch().
- Merge every className through cn(); spread the rest of the props on the root
  and expose data-stage so a consumer can style or assert on the stage.
- Accessibility: role="group" labelled by the heading; a 3-dot stepper as an
  <ol> with aria-current="step"; rows are <button role="checkbox"> with
  aria-checked (the header adds "mixed"); the read error is role="alert"; the
  bar is role="progressbar" with aria-valuenow/min/max plus an aria-valuetext
  that reads "3 of 12 imported"; the skip disclosure is aria-expanded +
  aria-controls. One sr-only role="status" region carries STAGE-level messages
  only — announcing every item would restart a screen reader twelve times.
- Motion is decoration: the spinner is animate-spin
  motion-reduce:animate-none, the bar has transition-[width]
  motion-reduce:transition-none, the disclosure chevron rotates with
  motion-reduce:transition-none. Nothing about the flow depends on it.
- Long titles truncate inside min-w-0 flex children; the review list scrolls in
  a bounded box instead of stretching the card to 400 rows.
- Dates format through a fixed-locale, UTC Intl.DateTimeFormat: export stamps
  are absolute, and a server-rendered date in a different zone is a hydration
  mismatch on every card.

Customization levers
- Dialects: each reader is one function (chatGptMessages / claudeMessages /
  genericMessages) plus one branch in the detector — add Gemini, Copilot or your
  own export by writing one more, or skip all of it by passing `parse`.
- Skip vocabulary: SKIP_LABEL maps reason → copy, SKIP_ORDER decides which group
  the user reads first, SKIP_SAMPLE (5) is how many labels a group shows before
  "and N more". Add a reason by adding one entry to each.
- FILTER_MIN (6) is when the filter box appears; the list's max-height is the
  only scroll knob. Drop both for a card that always shows everything.
- Row density: the row is a title line plus one meta line (turns · dates · first
  prompt) — drop the meta line for a compact list, or add a per-role turn
  breakdown from conversation.messages.
- Concurrency: the queue is sequential on purpose (progress that means
  something, and a rate-limited API that survives). Batch it by awaiting
  Promise.allSettled over slices if your backend prefers throughput, and keep
  the token + abort guards.
- Selection defaults: everything is selected on arrival; invert it (start empty)
  by seeding the excluded set with every id after parse, e.g. for an import that
  should be opt-in per conversation.
- autoStart turns the card into a two-stage flow for a source the user already
  confirmed; keeping the review step is the honest default when the data comes
  from a file they just picked.
- Stepper labels, heading and the "conversations" noun are the copy surface;
  everything below them is structure.

Concepts

  • Named skip — nothing is dropped silently: an unreadable line, an empty thread, a duplicate, an unchecked row and a rejected write all become the same kind of record (label + reason + detail) and travel together to the final summary, so "42 of 58 imported" always has 16 explanations under it.
  • Preview before commit — parsing is not importing. The review step is the only place where a foreign file is still just data: the user reads what was recognised, unchecks what they do not want, and can go back — and the moment the first write is issued that door closes and only Cancel is left.
  • Stale-parse token — each read claims an incrementing token and re-checks it after every await, so a second file dropped mid-parse wins outright and the older parse resolves into nothing instead of overwriting the newer preview.
  • One-shot run lock — the run claims a ref synchronously on the click that starts it, so a second Import press is refused rather than handing the consumer a conversation that is already in flight; a new file is what supersedes a run, and the older loop then notices the token moved and discards everything it collected rather than writing a summary over the live one.
  • Cancel lands now — the abort signal is raced against the consumer's promise by hand, so a write that ignores the signal cannot hold the UI: the in-flight conversation and everything after it are reported as not reached, because only the consumer knows whether that write actually landed.
  • Dialect per item — the format is detected on each entry (node graph, chat_messages, plain messages) rather than once per file, which is what lets a hand-merged export of two products import as one list and label itself "mixed sources".

On This Page