[eric] onboarding: starter prompt opens the composer pre-filled (unsent), user reviews + hits send; replaces the ghost preview

This commit is contained in:
ciregenz
2026-06-11 01:33:52 -07:00
parent 6a5e80fcd9
commit f282afb4fb
6 changed files with 55 additions and 50 deletions
+17 -1
View File
@@ -42,9 +42,12 @@ interface Props {
thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto';
onThinkingLevelChange?: (level: 'off' | 'low' | 'medium' | 'high' | 'auto') => void;
onActivityLabelChange?: (label: string | null) => void;
// Seed the composer with this text (unsent), so a starter-prompt click opens
// the chat with the message already typed, ready for the user to hit send.
prefillPrompt?: string;
}
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange }, ref) => {
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -59,6 +62,19 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (autoFocus) editorRef.current?.focus();
}, [autoFocus]);
// Drop the seeded prompt into the editor when it arrives (starter-prompt click).
const prefilledRef = useRef<string | null>(null);
useEffect(() => {
if (!prefillPrompt || prefilledRef.current === prefillPrompt) return;
const editor = editorRef.current;
if (!editor) return;
if (editor.tagName === 'TEXTAREA') (editor as unknown as HTMLTextAreaElement).value = prefillPrompt;
else editor.textContent = prefillPrompt;
setHasContent(true);
prefilledRef.current = prefillPrompt;
editor.focus();
}, [prefillPrompt]);
useDraftLoad(editorRef, ownerId);
const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId));
@@ -57,6 +57,8 @@ interface Props {
dashboardId?: string;
newAgentBounce?: boolean;
onNewAgentBounceEnd?: () => void;
// Text to seed the composer with when it opens (starter-prompt click).
prefillPrompt?: string;
}
const TOOLBAR_OWNER_ID = '__toolbar__';
@@ -100,7 +102,7 @@ function formatRelativeTime(dateStr: string | null): string {
}
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, onAddNote, dashboardId, newAgentBounce, onNewAgentBounceEnd }, ref) => {
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, onAddNote, dashboardId, newAgentBounce, onNewAgentBounceEnd, prefillPrompt }, ref) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
@@ -434,6 +436,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
sessionId={TOOLBAR_OWNER_ID}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={handleThinkingLevelChange}
prefillPrompt={prefillPrompt}
/>
</div>
) : historyOpen ? (
@@ -73,6 +73,8 @@ interface DashboardCanvasProps {
onNewAgent: () => void;
onToolbarCancel: () => void;
onToolbarSend: (...args: any[]) => void;
onStarterPrefill: (prompt: string) => void;
toolbarPrefill?: string;
onAddView: (outputId: string) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
@@ -130,6 +132,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onNewAgent,
onToolbarCancel,
onToolbarSend,
onStarterPrefill,
toolbarPrefill,
onAddView,
onHistoryResume,
onAddBrowser,
@@ -215,7 +219,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
<DashboardEmptyState c={c} onLaunch={onToolbarSend} />
<DashboardEmptyState c={c} onLaunch={onToolbarSend} onPrefill={onStarterPrefill} />
) : (
<div
ref={canvas.contentRef}
@@ -289,6 +293,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onFitToView={onFitToView}
onTidy={onTidy}
onSearchPaletteClose={onSearchPaletteClose}
toolbarPrefill={toolbarPrefill}
/>
</Box>
</>
@@ -62,7 +62,9 @@ const STARTER_CATEGORIES: StarterCategory[] = [
const DashboardEmptyState: React.FC<{
c: ClaudeTokens;
onLaunch?: (prompt: string, mode: string, model: string) => void;
}> = ({ c, onLaunch }) => {
// Open the composer with the prompt typed in (unsent) so the user hits send.
onPrefill?: (prompt: string) => void;
}> = ({ c, onLaunch, onPrefill }) => {
// The host hides Dashboard with visibility:hidden (not display:none), which keeps
// CSS animations ticking; gate on active so the shimmer only burns while watched.
const active = useDashboardActive();
@@ -72,7 +74,6 @@ const DashboardEmptyState: React.FC<{
const navigate = useNavigate();
const [launching, setLaunching] = React.useState(false);
const [expanded, setExpanded] = React.useState<string | null>(null);
const [hoveredPrompt, setHoveredPrompt] = React.useState<string | null>(null);
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
const currentPrompts = currentCategory?.prompts ?? [];
@@ -87,6 +88,12 @@ const DashboardEmptyState: React.FC<{
navigate(`/apps/new?prompt=${encodeURIComponent(prompt)}`);
return;
}
// Open the composer with the prompt typed in, unsent: the user sees the chat
// open with their message ready and clicks send. Falls back to direct launch.
if (onPrefill) {
onPrefill(prompt);
return;
}
if (!onLaunch) return;
setLaunching(true); // empty state unmounts on first session, but guard a fast double-click
onLaunch(prompt, mode, model);
@@ -194,16 +201,12 @@ const DashboardEmptyState: React.FC<{
>
<ArrowLeft size={15} /> back
</Box>
<Box
onMouseLeave={() => setHoveredPrompt(null)}
sx={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 0.9, width: '100%', maxWidth: 480 }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.9, width: '100%', maxWidth: 480 }}>
{currentPrompts.map((prompt) => (
<Box
component="button"
key={prompt}
onClick={() => launch(prompt)}
onMouseEnter={() => setHoveredPrompt(prompt)}
disabled={launching}
sx={{
textAlign: 'left',
@@ -223,45 +226,6 @@ const DashboardEmptyState: React.FC<{
{prompt}
</Box>
))}
{/* Hover preview: a translucent ghost of the chat that would open,
so the user sees their message land before committing. Pure
divs (not the heavy AgentChat), so it costs nothing to render. */}
<AnimatePresence>
{hoveredPrompt && (
<Box
component={motion.div}
key="ghost"
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 0.72, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.16 }}
sx={{
position: 'absolute',
left: '100%', top: '50%', ml: 3,
transform: 'translateY(-50%)',
width: 300,
pointerEvents: 'none',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: 3,
boxShadow: c.shadow.md,
p: 1.5,
}}
>
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.tertiary, mb: 1.2 }}>
New chat
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ maxWidth: '88%', bgcolor: c.bg.elevated, borderRadius: 2.5, px: 1.4, py: 0.9 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.primary, lineHeight: 1.4 }}>
{hoveredPrompt}
</Typography>
</Box>
</Box>
</Box>
)}
</AnimatePresence>
</Box>
</motion.div>
)}
@@ -41,6 +41,7 @@ interface DashboardOverlaysProps {
onFitToView: () => void;
onTidy: () => void;
onSearchPaletteClose: () => void;
toolbarPrefill?: string;
}
const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
@@ -68,6 +69,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
onFitToView,
onTidy,
onSearchPaletteClose,
toolbarPrefill,
}) => {
return (
<>
@@ -86,6 +88,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
dashboardId={dashboardId}
newAgentBounce={newAgentBounce}
onNewAgentBounceEnd={onNewAgentBounceEnd}
prefillPrompt={toolbarPrefill}
/>
</Box>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useCanvasControls } from '../interaction/useCanvasControls';
@@ -159,6 +159,18 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
setSearchPaletteOpen,
});
// Starter-prompt prefill: opens the composer with the prompt typed in (unsent),
// so the user reviews and hits send. Bump a nonce so re-clicking the same prompt
// (after editing/clearing) still re-seeds. Cleared when the composer closes.
const [toolbarPrefill, setToolbarPrefill] = useState<string | undefined>(undefined);
const handleStarterPrefill = useCallback((prompt: string) => {
setToolbarPrefill(prompt);
setToolbarOpen(true);
}, [setToolbarOpen]);
useEffect(() => {
if (!toolbarOpen && toolbarPrefill) setToolbarPrefill(undefined);
}, [toolbarOpen, toolbarPrefill]);
useDashboardClipboard({
isActive,
dashboardId,
@@ -253,6 +265,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection,
neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce,
toolbarRef, spawnOriginsRef, revealSpawnedRef, measuredHeightsRef, getCanvasState,
toolbarPrefill,
onStarterPrefill: handleStarterPrefill,
onViewportMouseDown: handleViewportMouseDown,
onViewportMouseMove: handleViewportMouseMove,
onViewportMouseUp: handleViewportMouseUp,