# SyncProgressPanel

> The dialog behind the Unibox sync button.

## Facts

- **Product**: Max
- **Family**: Unibox
- **Global**: `window.DigitalCrew.SyncProgressPanel`
- **Source**: `max-agent: src/features/unibox/ui/components/sync-progress-panel.tsx`
- **Import in the app**: `import { SyncProgressPanel } from "@/features/unibox/ui/components/sync-progress-panel";`
- **Live preview**: /design/components/sync-progress-panel/preview.html
- **Page**: /design/components/sync-progress-panel

## Guidelines

It shows what is importing, how far along it is and what it has done, so nobody stares at a spinner wondering whether the tab can close.

**Use it for** watching a mailbox, LinkedIn or WhatsApp import.

**What you provide**
- `open`, `onOpenChange` and `isSyncing`.
- `snapshot`: a `SyncProgressSnapshot` with `accounts` (each with a `phase`, counters, `last_error` and `since_heartbeat_ms`), `summary` (`total`, `running`, `settled`, `failed`, `messages_imported`, `percent`) and `observed_at`.
- `log`: `SyncLogEntry[]`, each `{ id, at, level, account?, message }`.
- `elapsedMs`, `onDetach` and `onSyncAgain`.

**Anatomy**
- The dialog is `sm:max-w-2xl`, capped at `max-h-[85vh]`, headed "Syncing 2 of 4 accounts".
- The overall bar counts accounts, not messages. Providers never say how much history is left.
- Account rows are `rounded-md border bg-card px-3 py-2`, ordered running, failed, queued, idle, done.
  - The spinner is `primary`, or `amber-500` after 3 minutes without a heartbeat.
  - Running rows sweep a `h-1` bar (`animate-unibox-sync-sweep`, 1.4s) above `text-[11px]` counters.
  - Failed rows state the error in plain words.
- Activity is a `h-40` monospace `text-[11px]` log that follows new lines. Info is muted, success `emerald-600`, warn `amber-600`, error destructive.
- The footer offers "Continue in background" while syncing, or "Close" and "Sync again" afterwards.

**Don't** invent a message percentage or hide disconnected accounts. They stay in the total, so the bar stops short of 100%.

## API

```ts
interface SyncProgressPanelProps {
    open: boolean;
    onOpenChange: (open: boolean) => void;
    isSyncing: boolean;
    snapshot: SyncProgressSnapshot | null;
    log: SyncLogEntry[];
    elapsedMs: number;
    /** Stop issuing passes and leave the rest to the background import. */
    onDetach: () => void;
    /** Start another run (shown once the current one has finished). */
    onSyncAgain: () => void;
}
export declare function SyncProgressPanel({ open, onOpenChange, isSyncing, snapshot, log, elapsedMs, onDetach, onSyncAgain, }: SyncProgressPanelProps): JSX.Element;
```

## Example

```html
<div id="root" class="p-6"></div>
<script>
(function () {
  var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
  var NOW = Date.now(), SEC = 1000;
  function iso(msAgo) { return new Date(NOW - msAgo).toISOString(); }
  function acct(o) {
    return Object.assign({
      account_status: "connected", backfilled: false, resumable: false, pages_processed: 0, messages_seen: 0,
      messages_imported: 0, chats_created: 0, attachments_seen: 0, last_error: null,
      started_at: iso(94 * SEC), finished_at: null, heartbeat_at: null, since_heartbeat_ms: null
    }, o);
  }
  var ACCOUNTS = [
    acct({ account_id: "a1", name: null, email: "mathieu@digitalcrew.ai", channel: "email", phase: "importing", resumable: true,
      pages_processed: 38, messages_seen: 2140, messages_imported: 1876, chats_created: 212, attachments_seen: 94,
      heartbeat_at: iso(4 * SEC), since_heartbeat_ms: 4000 }),
    acct({ account_id: "a2", name: "Mathieu Laurent", email: null, channel: "linkedin", phase: "catching-up", backfilled: true,
      pages_processed: 3, messages_seen: 48, messages_imported: 42, chats_created: 6, heartbeat_at: iso(2 * SEC), since_heartbeat_ms: 2000 }),
    acct({ account_id: "a3", name: "Sales team inbox", email: "sales@digitalcrew.ai", channel: "email", phase: "done", backfilled: true,
      pages_processed: 71, messages_seen: 3530, messages_imported: 3412, chats_created: 408, attachments_seen: 157, finished_at: iso(23 * SEC) }),
    acct({ account_id: "a4", name: "WhatsApp Business", email: null, channel: "whatsapp", phase: "failed", pages_processed: 2,
      messages_seen: 0, messages_imported: 0, finished_at: iso(54 * SEC),
      last_error: 'Unipile API error: 429 - {"status":429,"type":"errors/too_many_requests","title":"Too many requests"}' })
  ];
  var SNAPSHOT = {
    accounts: ACCOUNTS,
    summary: { total: 4, running: 2, settled: 1, failed: 1, messages_imported: 5330, chats_created: 626, percent: 50 },
    observed_at: new Date(NOW).toISOString()
  };
  function line(id, sAgo, level, message, account, channel) {
    return { id: id, at: NOW - sAgo * SEC, level: level, message: message, account: account, channel: channel, account_id: account ? account : undefined };
  }
  var LOG = [
    line("log-0", 94, "info", "Starting import across all connected accounts…"),
    line("log-1", 92, "info", "Resuming the interrupted import", "mathieu@digitalcrew.ai", "email"),
    line("log-2", 92, "info", "Checking for new messages", "Mathieu Laurent", "linkedin"),
    line("log-3", 92, "info", "Importing history", "Sales team inbox", "email"),
    line("log-4", 54, "error", "The provider rate-limited us. The import resumes on its own.", "WhatsApp Business", "whatsapp"),
    line("log-5", 47, "info", "+1,204 messages · 27 pages (1,204 messages so far)", "mathieu@digitalcrew.ai", "email"),
    line("log-6", 23, "success", "Done — imported 3,412 messages", "Sales team inbox", "email"),
    line("log-7", 6, "info", "+672 messages · 11 pages (1,876 messages so far)", "mathieu@digitalcrew.ai", "email"),
    line("log-8", 4, "info", "More history to pull — starting another pass.")
  ];
  function App() {
    var o = React.useState(true), open = o[0], setOpen = o[1];
    var e = React.useState(94 * SEC), elapsed = e[0], setElapsed = e[1];
    React.useEffect(function () {
      // The panel takes focus on open; drop it so the still shows no focus ring.
      var t = setTimeout(function () { if (document.activeElement) document.activeElement.blur(); }, 60);
      return function () { clearTimeout(t); };
    }, []);
    React.useEffect(function () {
      var id = setInterval(function () { setElapsed(function (x) { return x + SEC; }); }, SEC);
      return function () { clearInterval(id); };
    }, []);
    return h("div", { className: "flex items-center justify-between rounded-xl border border-border/60 bg-card px-4 py-3" },
      h("div", { className: "text-sm font-medium" }, "Inbox"),
      h(DC.Button, { variant: "outline", size: "sm", onClick: function () { setOpen(true); } }, h(I.Loader2, { className: "animate-spin" }), "Syncing…"),
      h(DC.SyncProgressPanel, {
        open: open, onOpenChange: setOpen, isSyncing: true, snapshot: SNAPSHOT, log: LOG, elapsedMs: elapsed,
        onDetach: function () {}, onSyncAgain: function () {}
      }));
  }
  ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>
```

## More in Unibox

- [ChannelSegments](/design/components/channel-segments.md): The Unibox channel filter: a rail of icons where one shared pill glides to the selected channel, and only that segment spells out its name.
- [ChatListItem](/design/components/chat-list-item.md): One conversation in the Unibox list: a motion row that staggers in, glides when a new reply reorders the inbox, and pops its unread badge.
- [MessageBubble](/design/components/message-bubble.md): One message in a Unibox thread: your side in the brand gradient, theirs in muted glass, each rising in from its own side of the conversation.
- [MessageComposer](/design/components/message-composer.md): The Unibox reply box: text, emoji and staged images in one rounded field, with a send button that springs awake when there is something to send.
- [ReplySuggestions](/design/components/reply-suggestions.md): The band between a Unibox thread and its composer: ask for a few replies, watch them arrive, pick one.
- [UniboxIdlePanel](/design/components/unibox-idle-panel.md): The Unibox's empty conversation pane: a breathing glass orb with the three channels in slow orbit and a quiet "Listening for new replies" line.
