Design
llms.txt digitalcrew.tech

Unibox

SyncProgressPanel

The dialog behind the Unibox sync button.

MaxUniboxDigitalCrew.SyncProgressPanel
SyncProgressPanel · liveOpen

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%.

Facts#

GroupUnibox
ProductMax
Globalwindow.DigitalCrew.SyncProgressPanel
Sourcemax-agent: src/features/unibox/ui/components/sync-progress-panel.tsx
Import in the appimport { SyncProgressPanel } from "@/features/unibox/ui/components/sync-progress-panel";

API#

TypeScript declarations emitted from src/features/unibox/ui/components/sync-progress-panel.tsx.

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#

The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.

<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>