diff --git a/src/components/sections/DashboardWorkspace.jsx b/src/components/sections/DashboardWorkspace.jsx
index b49b8e7..404b137 100644
--- a/src/components/sections/DashboardWorkspace.jsx
+++ b/src/components/sections/DashboardWorkspace.jsx
@@ -102,6 +102,20 @@ const EXAMPLE_PROMPTS = [
{ icon: Layers, title: 'Business overview', prompt: 'Build a dashboard with the most important KPIs and a couple of charts summarizing overall business health.' },
]
+// Rotates under the real live step while a build is in flight — genuine,
+// shippped-feature facts (not filler), so the wait teaches something instead
+// of just decorating it. Kept short; the real step above stays the primary line.
+const BUILD_TIPS = [
+ 'Every query is verified against your real data before it ever reaches the screen.',
+ 'Dashboards run read-only — the agent can look, never write.',
+ 'Ask to add a chart or change a metric any time — it edits in place.',
+ 'Every save keeps a version history, so you can always roll back a change.',
+ 'Publish a dashboard to get a read-only link — no login required to view it.',
+ 'Turn any dashboard into a TV/kiosk view that auto-refreshes on a wall display.',
+ 'Set a plain-English alert and DeepSQL checks it on a schedule for you.',
+ 'Duplicate a dashboard to branch it without touching the original.',
+]
+
// Focused, chrome-less builder. Left = the agent (generate / refine); main = the
// live dashboard canvas. The DeepSQL logo and breadcrumb return to the gallery.
//
@@ -234,6 +248,17 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [messages, thinking])
+ // Rotates BUILD_TIPS while the main canvas shows the "Building…" state, so a
+ // 15-30s wait has something changing on screen beyond the elapsed-time
+ // ticker. Starts at a random index (not always the same first tip) and
+ // resets once the build ends so the next one starts fresh.
+ const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * BUILD_TIPS.length))
+ useEffect(() => {
+ if (!thinking) return undefined
+ const id = setInterval(() => setTipIndex((i) => (i + 1) % BUILD_TIPS.length), 3500)
+ return () => clearInterval(id)
+ }, [thinking])
+
const refreshNow = useCallback(() => {
artifactRef.current?.reload()
setLastRefreshedAt(Date.now())
@@ -891,17 +916,25 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
)}
>
- ) : (
+ ) : thinking ? (
-
-
{thinking ? 'Building your dashboard…' : 'Nothing built here yet'}
+
+
Building your dashboard…
- {thinking
- ? 'The agent is grounding on your schema and verifying every query — this usually takes a bit.'
- : 'This dashboard doesn’t have a build yet — the chat on the left may just be planning so far. Ask for a chart to get started.'}
+ {steps.length > 0 ? steps[steps.length - 1].message : 'Grounding on your schema and verifying every query…'}
+ · {elapsed}s
- {!thinking && }
+
{BUILD_TIPS[tipIndex]}
+
+
+ ) : (
+
+
+
+
Nothing built here yet
+
This dashboard doesn’t have a build yet — the chat on the left may just be planning so far. Ask for a chart to get started.
+
)}
diff --git a/src/components/sections/DashboardWorkspace.module.css b/src/components/sections/DashboardWorkspace.module.css
index b1c0ebf..11eeaa5 100644
--- a/src/components/sections/DashboardWorkspace.module.css
+++ b/src/components/sections/DashboardWorkspace.module.css
@@ -1243,9 +1243,58 @@
box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08);
}
+/* Same shape as .newIcon, but alive while a build is in flight — a static
+ sparkle here reads as frozen given the sidebar's own trace is animating. */
+.newIconPulse {
+ width: 52px;
+ height: 52px;
+ border-radius: 14px;
+ background: #EEEDFE;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 4px;
+ box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08);
+ animation: dsqlBuildPulse 1.8s ease-in-out infinite;
+}
+
+@keyframes dsqlBuildPulse {
+ 0%, 100% { transform: scale(1); box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08), 0 0 0 0 rgba(83, 74, 183, 0.18); }
+ 50% { transform: scale(1.06); box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08), 0 0 0 10px rgba(83, 74, 183, 0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .newIconPulse { animation: none; }
+}
+
.newTitle { font-size: 19px; font-weight: 650; letter-spacing: -0.014em; color: #111; margin: 0; }
.newSub { font-size: 13px; color: #777; max-width: 380px; line-height: 1.6; margin: 0; }
+.buildElapsed { color: #a3a3a3; font-variant-numeric: tabular-nums; }
+
+/* Rotates every few seconds (see BUILD_TIPS) — the `key={tipIndex}` on the
+ element itself retriggers this fade-in each time the tip text changes. */
+.buildTip {
+ margin: 18px 0 0;
+ padding: 10px 16px;
+ max-width: 420px;
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: #534AB7;
+ background: #F5F3FF;
+ border-radius: 10px;
+ animation: dsqlTipIn 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+@keyframes dsqlTipIn {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: none; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .buildTip { animation: none; }
+}
+
.exampleGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
diff --git a/src/components/sections/DashboardsHome.jsx b/src/components/sections/DashboardsHome.jsx
index e5a3c70..6b48bc0 100644
--- a/src/components/sections/DashboardsHome.jsx
+++ b/src/components/sections/DashboardsHome.jsx
@@ -50,9 +50,19 @@ function Thumb({ id }) {
)
}
+// Placeholder count for the initial-load skeleton — enough to fill a typical
+// viewport width without over-committing to a specific grid density.
+const SKELETON_COUNT = 8
+
export default function DashboardsHome({ connectionId, onOpen }) {
const [dashboards, setDashboards] = useState([])
- const [loading, setLoading] = useState(false)
+ // Starts true (not false) so the very first render — before the fetch effect
+ // has even run — shows the loading skeleton, not the "No dashboards yet"
+ // empty state. Without this, dashboards.length === 0 && !loading was
+ // momentarily true on every mount, flashing the empty state before the real
+ // list (or genuine empty state) landed a beat later.
+ const [loading, setLoading] = useState(true)
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
const [filter, setFilter] = useState('all')
const [folder, setFolder] = useState(null) // null = all folders
const [search, setSearch] = useState('')
@@ -161,9 +171,14 @@ export default function DashboardsHome({ connectionId, onOpen }) {
setDashboards([])
} finally {
setLoading(false)
+ setHasLoadedOnce(true)
}
}, [connectionId])
+ // Switching connections must re-show the skeleton, not the previous
+ // connection's cards (or a premature "no dashboards" for the new one) while
+ // the new fetch is in flight.
+ useEffect(() => { setHasLoadedOnce(false); setLoading(true) }, [connectionId])
useEffect(() => { load() }, [load])
const folders = useMemo(() => {
@@ -241,7 +256,19 @@ export default function DashboardsHome({ connectionId, onOpen }) {
))}
- {!loading && dashboards.length === 0 ? (
+ {!hasLoadedOnce ? (
+