Skip to content

Commit 2635a54

Browse files
notSumit25claude
andcommitted
feat(dashboards): v0.dev-style centered intro and editable breadcrumb title
A brand-new dashboard now opens with the composer centered and no side panel/canvas chrome, docking smoothly into the left panel the instant the first message is sent instead of showing the docked layout from the start. Driven by whether a user message exists yet, so reopening any dashboard with prior history skips straight to the normal docked layout. The breadcrumb now reads "Untitled dashboard" (was "New dashboard") and is directly editable inline. A rename takes priority over both the agent's HTML-derived title and the saved row's name, and survives future builds — including a fix for the build-completion path, which persists its own HTML-derived name server-side and would otherwise silently revert a pending rename the moment a build finished. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f1f5a4c commit 2635a54

3 files changed

Lines changed: 297 additions & 32 deletions

File tree

src/components/sections/DashboardWorkspace.jsx

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
2-
import { LineChart, ArrowUp, ChevronLeft, Sparkles, Check, Loader2, Brain, PencilRuler, ClipboardCheck, TrendingUp, Users, PieChart, Layers, Code2, X, Copy, Undo2, Play, Database, History, RotateCcw, Eye, RefreshCw, ChevronDown, BellRing, Trash2 } from 'lucide-react'
2+
import { LineChart, ArrowUp, ChevronLeft, Sparkles, Check, Loader2, Brain, PencilRuler, Pencil, ClipboardCheck, TrendingUp, Users, PieChart, Layers, Code2, X, Copy, Undo2, Play, Database, History, RotateCcw, Eye, RefreshCw, ChevronDown, BellRing, Trash2 } from 'lucide-react'
33
import DashboardArtifact from '@/components/DashboardArtifact'
44
import ShareMenu from './ShareMenu'
55
import { savedDashboardsAPI } from '@/lib/api/client'
@@ -117,8 +117,8 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
117117
const key = keyRef.current
118118

119119
const session = useDashboardSession(key)
120-
const { ensureSession, patchSession, releaseAlias, submitPrompt, resumeIfRunning, persistDraft } = useDashboardChatActions()
121-
const { messages, thinking, steps, startedAt, config, savedId, dirty, liveShell, liveWidget, renderHtml } = session
120+
const { ensureSession, patchSession, releaseAlias, submitPrompt, resumeIfRunning, persistDraft, renameDashboard } = useDashboardChatActions()
121+
const { messages, thinking, steps, startedAt, config, savedId, dirty, liveShell, liveWidget, renderHtml, titleOverride } = session
122122

123123
const [input, setInput] = useState('')
124124
const [elapsed, setElapsed] = useState(0)
@@ -281,14 +281,54 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
281281
// to show, and resetting again here would wrongly wipe the ones just logged.
282282
useEffect(() => { if (liveShell) setQueries([]) }, [liveShell])
283283

284-
const name = config?.title || (isNew ? 'New dashboard' : dashboard?.name) || 'Dashboard'
284+
// A user's explicit rename always wins — over both the HTML-derived title
285+
// and the saved row's name — until they rename again (see renameDashboard).
286+
const name = titleOverride || config?.title || (isNew ? 'Untitled dashboard' : dashboard?.name) || 'Untitled dashboard'
285287
// "Live" once published to the web; the ShareMenu keeps this in sync.
286288
const status = isPublic ? 'live' : 'draft'
287289
const sourceDirty = sourceDraft !== (config?.html || '')
288290

291+
const [editingTitle, setEditingTitle] = useState(false)
292+
const [titleDraft, setTitleDraft] = useState(name)
293+
const titleInputRef = useRef(null)
294+
295+
function startEditingTitle() {
296+
setTitleDraft(name)
297+
setEditingTitle(true)
298+
}
299+
function commitTitle() {
300+
setEditingTitle(false)
301+
const trimmed = titleDraft.trim()
302+
if (!trimmed || trimmed === name) return
303+
renameDashboard(key, trimmed)
304+
}
305+
306+
useEffect(() => {
307+
if (editingTitle) { titleInputRef.current?.focus(); titleInputRef.current?.select() }
308+
}, [editingTitle])
309+
310+
// v0.dev-style intro: a brand-new dashboard starts with the composer centered
311+
// and no side panel/canvas chrome at all. The instant the first user message
312+
// is sent, it docks into the left panel and the canvas takes over — driven by
313+
// whether a user message exists yet (not local state), so it resolves
314+
// correctly even if this component remounts mid-turn (e.g. reopening a
315+
// dashboard whose first turn already completed skips the intro entirely).
316+
const hasUserMessage = messages.some((m) => m.role === 'user')
317+
const [docking, setDocking] = useState(false)
318+
// Keep rendering the intro layout for one animation cycle after the message
319+
// that ends it — flipping the layout in the same commit as the message would
320+
// cut straight to the docked panel instead of gliding into it.
321+
const intro = isNew && (!hasUserMessage || docking)
322+
useEffect(() => {
323+
if (!docking) return undefined
324+
const id = setTimeout(() => setDocking(false), 420)
325+
return () => clearTimeout(id)
326+
}, [docking])
327+
289328
function submit(directPrompt) {
290329
const prompt = (directPrompt ?? input).trim()
291330
if (!prompt || thinking) return
331+
if (isNew && !hasUserMessage) setDocking(true)
292332
setInput('')
293333
submitPrompt(key, connectionId, prompt)
294334
}
@@ -445,7 +485,24 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
445485
</button>
446486
<button className={styles.crumbLink} onClick={onClose}>Dashboards</button>
447487
<span className={styles.sep}>/</span>
448-
<span className={styles.crumbCur}>{name}</span>
488+
{editingTitle ? (
489+
<input
490+
ref={titleInputRef}
491+
className={styles.crumbInput}
492+
value={titleDraft}
493+
onChange={(e) => setTitleDraft(e.target.value)}
494+
onBlur={commitTitle}
495+
onKeyDown={(e) => {
496+
if (e.key === 'Enter') { e.preventDefault(); commitTitle() }
497+
if (e.key === 'Escape') { e.preventDefault(); setEditingTitle(false) }
498+
}}
499+
/>
500+
) : (
501+
<button className={styles.crumbEditable} onClick={startEditingTitle} title="Rename dashboard">
502+
<span className={styles.crumbCur}>{name}</span>
503+
<Pencil size={12} className={styles.crumbEditIcon} />
504+
</button>
505+
)}
449506
<span className={styles.spacer} />
450507
<span className={status === 'live' ? styles.pillLive : styles.pillDraft}>{status === 'live' ? 'Live' : 'Draft'}</span>
451508
{config && (dirty || !savedId) && (
@@ -462,15 +519,22 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
462519
/>
463520
</header>
464521

465-
<div className={styles.body}>
466-
<aside className={styles.agent}>
467-
<div className={styles.agentScroll} ref={scrollRef}>
468-
{messages.map((msg, i) => (
522+
<div className={intro ? styles.bodyIntro : styles.body}>
523+
<aside className={intro ? `${styles.agentIntro} ${docking ? styles.agentDocking : ''}` : styles.agent}>
524+
{intro && !docking && (
525+
<div className={styles.introHead}>
526+
<span className={styles.newIcon}><Sparkles size={22} color="#534AB7" /></span>
527+
<h2 className={styles.newTitle}>Build a dashboard</h2>
528+
<p className={styles.newSub}>Describe what you want and the DeepSQL agent builds it — read-only, grounded on your data.</p>
529+
</div>
530+
)}
531+
<div className={intro ? styles.agentScrollIntro : styles.agentScroll} ref={scrollRef}>
532+
{!intro && messages.map((msg, i) => (
469533
<div key={i} className={msg.role === 'user' ? styles.bubbleUser : (msg.error ? styles.bubbleErr : styles.bubbleAgent)}>
470534
{msg.text}
471535
</div>
472536
))}
473-
{thinking && (
537+
{!intro && thinking && (
474538
<div className={styles.trace}>
475539
<div className={styles.traceHead}>
476540
<span className={styles.traceHeadLabel}>
@@ -501,7 +565,18 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
501565
</div>
502566
)}
503567
</div>
504-
<div className={styles.composer}>
568+
{intro && !docking && (
569+
<div className={styles.exampleGridIntro}>
570+
{EXAMPLE_PROMPTS.map(({ icon: Icon, title, prompt }) => (
571+
<button key={title} className={styles.exampleCard} onClick={() => submit(prompt)} disabled={thinking}>
572+
<span className={styles.exampleIcon}><Icon size={16} /></span>
573+
<span className={styles.exampleTitle}>{title}</span>
574+
<span className={styles.examplePrompt}>{prompt}</span>
575+
</button>
576+
))}
577+
</div>
578+
)}
579+
<div className={intro ? `${styles.composerIntro} ${docking ? styles.composerDocking : ''}` : styles.composer}>
505580
<textarea
506581
ref={inputRef}
507582
rows={1}
@@ -514,8 +589,10 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
514589
/>
515590
<button className={styles.sendBtn} onClick={() => submit()} disabled={thinking || !input.trim()} aria-label="Send"><ArrowUp size={16} /></button>
516591
</div>
592+
{intro && !docking && <button className={styles.backLinkIntro} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>}
517593
</aside>
518594

595+
{!intro && (
519596
<main className={styles.canvas}>
520597
{config?.html || liveConfig ? (
521598
<>
@@ -818,33 +895,18 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
818895
<div className={styles.newCanvas}>
819896
<div className={styles.newCanvasInner}>
820897
<span className={styles.newIcon}><Sparkles size={22} color="#534AB7" /></span>
821-
<h2 className={styles.newTitle}>{isNew ? 'Build a dashboard' : 'Nothing built here yet'}</h2>
898+
<h2 className={styles.newTitle}>{thinking ? 'Building your dashboard' : 'Nothing built here yet'}</h2>
822899
<p className={styles.newSub}>
823-
{isNew
824-
? 'Describe what you want on the left and the DeepSQL agent builds it here — read-only, grounded on your data.'
900+
{thinking
901+
? 'The agent is grounding on your schema and verifying every query — this usually takes a bit.'
825902
: '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.'}
826903
</p>
827-
828-
<div className={styles.exampleGrid}>
829-
{EXAMPLE_PROMPTS.map(({ icon: Icon, title, prompt }) => (
830-
<button
831-
key={title}
832-
className={styles.exampleCard}
833-
onClick={() => submit(prompt)}
834-
disabled={thinking}
835-
>
836-
<span className={styles.exampleIcon}><Icon size={16} /></span>
837-
<span className={styles.exampleTitle}>{title}</span>
838-
<span className={styles.examplePrompt}>{prompt}</span>
839-
</button>
840-
))}
841-
</div>
842-
843-
<button className={styles.backLink} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>
904+
{!thinking && <button className={styles.backLink} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>}
844905
</div>
845906
</div>
846907
)}
847908
</main>
909+
)}
848910
</div>
849911
</div>
850912
)

src/components/sections/DashboardWorkspace.module.css

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,45 @@
7171
white-space: nowrap;
7272
max-width: 320px;
7373
}
74+
75+
.crumbEditable {
76+
display: inline-flex;
77+
align-items: center;
78+
gap: 6px;
79+
border: none;
80+
background: transparent;
81+
padding: 4px 6px;
82+
margin: 0 -6px;
83+
border-radius: 7px;
84+
cursor: text;
85+
min-width: 0;
86+
transition: background 120ms ease-out;
87+
}
88+
.crumbEditable:hover { background: #f2f2f2; }
89+
90+
.crumbEditIcon {
91+
flex-shrink: 0;
92+
color: #b0b0b0;
93+
transition: color 120ms ease-out;
94+
}
95+
.crumbEditable:hover .crumbEditIcon { color: #777; }
96+
97+
.crumbInput {
98+
font-size: 14px;
99+
font-weight: 590;
100+
color: #111;
101+
letter-spacing: -0.006em;
102+
border: 1px solid #cfcaf0;
103+
border-radius: 7px;
104+
background: #fff;
105+
padding: 3px 7px;
106+
margin: 0 -7px;
107+
width: 220px;
108+
max-width: 320px;
109+
outline: none;
110+
box-shadow: 0 0 0 3px rgba(83, 74, 183, 0.12);
111+
}
112+
74113
.spacer { flex: 1; }
75114

76115
.pillLive,
@@ -124,6 +163,137 @@
124163
gap: 10px;
125164
}
126165

166+
/* v0.dev-style intro: before the first message, the composer sits centered in
167+
an otherwise-empty workspace. The instant the user sends, this whole column
168+
collapses from full-width to the 340px docked panel and its centered content
169+
becomes top-aligned — both genuinely animatable (width, margin, transform),
170+
so the dock reads as one continuous motion rather than a cut. The canvas
171+
mounts a beat later (it isn't rendered at all during intro), sliding the
172+
docked panel's border-right into place as it appears. */
173+
.bodyIntro {
174+
display: flex;
175+
flex: 1;
176+
min-height: 0;
177+
background:
178+
radial-gradient(ellipse 700px 420px at 50% 38%, rgba(83, 74, 183, 0.06), transparent 70%),
179+
#fff;
180+
}
181+
182+
.agentIntro {
183+
width: 100%;
184+
flex-shrink: 0;
185+
display: flex;
186+
flex-direction: column;
187+
align-items: center;
188+
min-height: 0;
189+
background: transparent;
190+
padding: 32px 24px;
191+
overflow-y: auto;
192+
transition: width 420ms cubic-bezier(0.2, 0.8, 0.2, 1), background 420ms ease-out;
193+
}
194+
195+
.agentIntro::before,
196+
.agentIntro::after {
197+
content: '';
198+
flex: 1;
199+
transition: flex-basis 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
200+
}
201+
202+
/* Fired the instant the first message is sent: collapse the centered hero
203+
down to the docked panel's width. The heading/example grid have already
204+
unmounted (see the JSX's `!docking` guards) so only the composer is left
205+
to glide from a wide centered card into the narrow docked composer. */
206+
.agentDocking {
207+
width: 340px;
208+
background: #fafafa;
209+
}
210+
.agentDocking::before { flex: 0 1 0%; }
211+
.agentDocking::after { flex: 1 1 auto; }
212+
213+
@media (prefers-reduced-motion: reduce) {
214+
.agent, .agentIntro, .agentDocking { transition: none; animation: none; }
215+
}
216+
217+
.introHead {
218+
display: flex;
219+
flex-direction: column;
220+
align-items: center;
221+
gap: 10px;
222+
text-align: center;
223+
width: 100%;
224+
max-width: 640px;
225+
margin-bottom: 22px;
226+
animation: introFade 380ms cubic-bezier(0.2, 0.8, 0.2, 1);
227+
}
228+
229+
@keyframes introFade {
230+
from { opacity: 0; transform: translateY(8px); }
231+
to { opacity: 1; transform: translateY(0); }
232+
}
233+
234+
@media (prefers-reduced-motion: reduce) {
235+
.introHead, .exampleGridIntro { animation: none; }
236+
}
237+
238+
.agentScrollIntro { display: none; }
239+
240+
.composerIntro {
241+
display: flex;
242+
align-items: flex-end;
243+
gap: 8px;
244+
width: 100%;
245+
max-width: 640px;
246+
padding: 10px;
247+
border: 1px solid #e2e2e2;
248+
border-radius: 16px;
249+
background: #fff;
250+
box-shadow: 0 12px 32px -14px rgba(83, 74, 183, 0.22), 0 2px 8px rgba(0, 0, 0, 0.04);
251+
animation: introFade 380ms cubic-bezier(0.2, 0.8, 0.2, 1);
252+
transition: max-width 420ms cubic-bezier(0.2, 0.8, 0.2, 1), border-radius 420ms ease-out,
253+
box-shadow 420ms ease-out, margin 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
254+
}
255+
256+
.composerDocking {
257+
max-width: 100%;
258+
margin: 0 -12px -12px;
259+
width: calc(100% + 24px);
260+
border-radius: 0;
261+
box-shadow: none;
262+
border-left: none;
263+
border-right: none;
264+
border-bottom: none;
265+
}
266+
267+
.exampleGridIntro {
268+
display: grid;
269+
grid-template-columns: repeat(2, minmax(0, 1fr));
270+
gap: 10px;
271+
width: 100%;
272+
max-width: 640px;
273+
margin: 16px 0 20px;
274+
animation: introFade 380ms cubic-bezier(0.2, 0.8, 0.2, 1);
275+
}
276+
277+
@media (max-width: 620px) {
278+
.exampleGridIntro { grid-template-columns: 1fr; }
279+
}
280+
281+
.backLinkIntro {
282+
display: inline-flex;
283+
align-items: center;
284+
gap: 4px;
285+
margin-top: 20px;
286+
border: 1px solid #e2e2e2;
287+
background: #fff;
288+
border-radius: 9px;
289+
padding: 7px 12px;
290+
font-size: 13px;
291+
color: #555;
292+
cursor: pointer;
293+
transition: background 120ms ease-out;
294+
}
295+
.backLinkIntro:hover { background: #f3f3f3; }
296+
127297
.bubbleAgent {
128298
background: #fff;
129299
border: 1px solid #eeeeee;

0 commit comments

Comments
 (0)