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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-import.jsonPrompt
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, plainmessages) 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".
Data Controls
An AI privacy panel where every control saves on its own — a training consent switch with a plain-language consequence line, a retention scale that warns when it deletes nothing, an export job with progress, and a type-to-confirm deletion.
AI Form Assist
Model-filled form fields — a sparkle per row, ghost-rendered values with a confidence tint, source popovers, a staggered fill-all wave, and per-field accept, dismiss and undo.