From dbcef2d86b8d44944daa8380d4263d2dde7f0c08 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 26 Aug 2026 16:43:09 +0200 Subject: [PATCH] feat: chat-style agent onboarding mockup at /agent/onboarding Prototype of a guided spawn flow: after the initial prompt the agent asks clarifying questions in the transcript, plays back a solidified brief, then collects cadence, quality bar, delivery and sources as inline controls with preselected values. Settings can be loaded from the most recent agent, taken as defaults, or walked through one by one. Enter on an empty composer accepts the current question so the whole flow can be stepped through quickly. Mockup only: no API calls, recent settings come from the interest mock. Co-Authored-By: Claude Fable 5 --- .../components/AgentOnboardingScreen.tsx | 1342 +++++++++++++++++ packages/webapp/pages/agent/onboarding.tsx | 49 + 2 files changed, 1391 insertions(+) create mode 100644 packages/shared/src/features/interests/components/AgentOnboardingScreen.tsx create mode 100644 packages/webapp/pages/agent/onboarding.tsx diff --git a/packages/shared/src/features/interests/components/AgentOnboardingScreen.tsx b/packages/shared/src/features/interests/components/AgentOnboardingScreen.tsx new file mode 100644 index 0000000000..93c4f3241b --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentOnboardingScreen.tsx @@ -0,0 +1,1342 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import Markdown from '../../../components/Markdown'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { Radio } from '../../../components/fields/Radio'; +import { Switch } from '../../../components/fields/Switch'; +import { Slider } from '../../../components/fields/Slider'; +import { TimerIcon, VIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import type { + CreateInterestSettings, + InterestOutputModes, + InterestSources, + UserInterest, +} from '../../../graphql/interests'; +import { + UserInterestCadence, + defaultCreateInterestSettings, +} from '../../../graphql/interests'; +import { useAgentShellHeight } from '../shell'; +import { transcriptProse } from '../prose'; +import { composerBar, composerColumn, composerFrame } from './AgentComposer'; +import { AgentSendButton } from './AgentSendButton'; +import { AgentThinkingOrb } from './AgentThinkingOrb'; +import { cadenceOptions, outputOptions } from './AgentSettingsFields'; + +type Choice = { value: string; label: string; hint?: string }; + +type OnboardingSettings = CreateInterestSettings & { + sources: InterestSources; + outputModes: InterestOutputModes; +}; + +type Stage = + | 'angle' + | 'exclude' + | 'brief' + | 'settingsChoice' + | 'cadence' + | 'fomo' + | 'delivery' + | 'sources' + | 'review' + | 'done'; + +type Control = + | { kind: 'chips'; choices: Choice[]; multi?: boolean } + | { kind: 'actions'; choices: Choice[] } + | { kind: 'brief' } + | { kind: 'cadence' } + | { kind: 'fomo' } + | { kind: 'delivery' } + | { kind: 'sources' } + | { kind: 'review'; fromRecent?: boolean } + | { kind: 'done' }; + +type Message = { + id: string; + role: 'user' | 'agent'; + html?: string; + text?: string; + control?: Control; + answer?: string; + isPending?: boolean; +}; + +const thinkMs = 900; +const maxFieldHeight = 120; + +let sequence = 0; +const nextId = (): string => { + sequence += 1; + return `onb-${sequence}`; +}; + +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +const angleChoices: Choice[] = [ + { value: 'shipped', label: 'Things that actually shipped' }, + { value: 'deep', label: 'Deep dives & source code' }, + { value: 'howto', label: 'Tutorials & how-tos' }, + { value: 'opinion', label: 'Opinions & debate' }, + { value: 'all', label: 'Everything, I’ll filter' }, +]; + +const excludeChoices: Choice[] = [ + { value: 'beginner', label: 'Beginner intros' }, + { value: 'marketing', label: 'Vendor marketing' }, + { value: 'paywalled', label: 'Paywalled' }, + { value: 'talks', label: 'Conference talks' }, + { value: 'none', label: 'Nothing, keep it wide' }, +]; + +const settingsChoices: Choice[] = [ + { + value: 'recent', + label: 'Load my recent settings', + hint: 'Same as your last agent', + }, + { + value: 'defaults', + label: 'Use the defaults', + hint: 'Whenever it matters · balanced · feed + posts + notifications', + }, + { value: 'stepwise', label: 'Ask me one by one' }, +]; + +const sourceOptions = [ + { key: 'dailyDev', label: 'daily.dev', disabled: false }, + { key: 'web', label: 'The web', disabled: true }, + { key: 'github', label: 'GitHub', disabled: true }, +] as const; + +const labelsFor = (choices: Choice[], values: string[]): string[] => + choices + .filter(({ value }) => values.includes(value)) + .map(({ label }) => label); + +const fomoLabel = (threshold: number): string => { + if (threshold > 0.7) { + return 'Only the best'; + } + if (threshold < 0.3) { + return 'Show me everything'; + } + return 'Balanced'; +}; + +const cadenceLabel = (cadence?: UserInterestCadence): string => + cadenceOptions.find(({ value }) => value === cadence)?.label ?? + 'Whenever it matters'; + +const deliveryLabel = (modes?: Partial): string => + outputOptions + .filter(({ key }) => modes?.[key]) + .map(({ short }) => short) + .join(', ') || 'nothing'; + +const sourcesLabel = (sources?: Partial): string => + sourceOptions + .filter(({ key }) => sources?.[key]) + .map(({ label }) => label) + .join(', ') || 'nowhere'; + +const buildBrief = ({ + query, + angles, + excludes, +}: { + query: string; + angles: string[]; + excludes: string[]; +}): string => { + const wants = labelsFor(angleChoices, angles).filter( + (label) => label !== 'Everything, I’ll filter', + ); + const skips = labelsFor(excludeChoices, excludes).filter( + (label) => label !== 'Nothing, keep it wide', + ); + const parts = [`Track ${query}.`]; + + if (wants.length) { + parts.push(`Prioritise ${wants.join(', ').toLowerCase()}.`); + } + if (skips.length) { + parts.push(`Skip ${skips.join(', ').toLowerCase()}.`); + } + parts.push('Only surface what a senior engineer would stop and read.'); + + return parts.join(' '); +}; + +const settingsFromInterest = (interest: UserInterest): OnboardingSettings => ({ + cadence: interest.cadence, + fomoThreshold: interest.fomoThreshold, + outputModes: interest.outputModes, + sources: interest.sources, +}); + +const baseSettings: OnboardingSettings = { + ...defaultCreateInterestSettings, + outputModes: { + feed: true, + post: true, + digest: false, + notification: true, + ...defaultCreateInterestSettings.outputModes, + }, + sources: { dailyDev: true, web: false, github: false }, +}; + +const Bubble = ({ + children, + answered, +}: { + children: ReactNode; + answered?: boolean; +}): ReactElement => ( + + {children} + +); + +const Answered = ({ text }: { text: string }): ReactElement => ( + + + + {text} + + +); + +const ContinueRow = ({ + label = 'Continue', + onClick, + secondary, +}: { + label?: string; + onClick: () => void; + secondary?: { label: string; onClick: () => void }; +}): ReactElement => ( + + + {secondary && ( + + )} + +); + +const ChipsControl = ({ + choices, + multi, + answer, + selected, + onToggle, + onAnswer, +}: { + choices: Choice[]; + multi?: boolean; + answer?: string; + selected: string[]; + onToggle: (value: string) => void; + onAnswer: (values: string[]) => void; +}): ReactElement => { + if (answer) { + return ; + } + + const toggle = (value: string) => { + if (!multi) { + onAnswer([value]); + return; + } + onToggle(value); + }; + + return ( + + + {choices.map(({ value, label }) => { + const isOn = selected.includes(value); + return ( + + ); + })} + + {multi && ( + + + + or type your own below · Enter to continue + + + )} + + ); +}; + +const ActionsControl = ({ + choices, + answer, + onAnswer, +}: { + choices: Choice[]; + answer?: string; + onAnswer: (value: string) => void; +}): ReactElement => { + if (answer) { + return ; + } + + return ( + + {choices.map(({ value, label, hint }) => ( + + ))} + + ); +}; + +const BriefControl = ({ + brief, + answer, + onConfirm, + onEdit, +}: { + brief: string; + answer?: string; + onConfirm: () => void; + onEdit: () => void; +}): ReactElement => ( + +
+ + {brief} + +
+ {answer ? ( + + ) : ( + + )} +
+); + +const CadenceControl = ({ + value, + answer, + onChange, + onConfirm, +}: { + value: UserInterestCadence; + answer?: string; + onChange: (cadence: UserInterestCadence) => void; + onConfirm: () => void; +}): ReactElement => + answer ? ( + + ) : ( + + + + + ); + +const FomoControl = ({ + value, + answer, + onChange, + onConfirm, +}: { + value: number; + answer?: string; + onChange: (threshold: number) => void; + onConfirm: () => void; +}): ReactElement => + answer ? ( + + ) : ( + + onChange(next)} + thumbLabel="FOMO vs quality" + /> + + + Show me everything + + + {fomoLabel(value)} + + + Only the best + + + + + ); + +const DeliveryControl = ({ + value, + answer, + onChange, + onConfirm, +}: { + value: InterestOutputModes; + answer?: string; + onChange: (modes: Partial) => void; + onConfirm: () => void; +}): ReactElement => + answer ? ( + + ) : ( + + {outputOptions.map(({ key, label, hint }) => ( + + onChange({ [key]: !value[key] })} + > + {label} + + + {hint} + + + ))} + + + ); + +const SourcesControl = ({ + value, + answer, + onChange, + onConfirm, +}: { + value: InterestSources; + answer?: string; + onChange: (sources: Partial) => void; + onConfirm: () => void; +}): ReactElement => + answer ? ( + + ) : ( + + {sourceOptions.map(({ key, label, disabled }) => ( + onChange({ [key]: !value[key] })} + > + {label} + {disabled && ( + + soon + + )} + + ))} + + + ); + +const ReviewControl = ({ + brief, + settings, + answer, + onChange, + onConfirm, +}: { + brief: string; + settings: OnboardingSettings; + answer?: string; + onChange: (next: Partial) => void; + onConfirm: () => void; +}): ReactElement => { + const [editing, setEditing] = useState< + 'cadence' | 'fomo' | 'delivery' | 'sources' + >(); + + const rows: { + key: 'cadence' | 'fomo' | 'delivery' | 'sources'; + label: string; + value: string; + }[] = [ + { + key: 'cadence', + label: 'When it reports', + value: cadenceLabel(settings.cadence), + }, + { + key: 'fomo', + label: 'FOMO vs quality', + value: fomoLabel(settings.fomoThreshold ?? 0.5), + }, + { + key: 'delivery', + label: 'What it delivers', + value: deliveryLabel(settings.outputModes), + }, + { + key: 'sources', + label: 'Where it looks', + value: sourcesLabel(settings.sources), + }, + ]; + + return ( + +
+ + {brief} + +
+ + {rows.map(({ key, label, value }) => ( + + + + {label} + + + {value} + + {!answer && ( + + )} + + {editing === key && ( +
+ {key === 'cadence' && ( + onChange({ cadence })} + /> + )} + {key === 'fomo' && ( + + onChange({ fomoThreshold: next }) + } + thumbLabel="FOMO vs quality" + /> + )} + {key === 'delivery' && ( + + {outputOptions.map(({ key: mode, label: modeLabel }) => ( + + onChange({ + outputModes: { + ...settings.outputModes, + [mode]: !settings.outputModes[mode], + }, + }) + } + > + {modeLabel} + + ))} + + )} + {key === 'sources' && ( + + {sourceOptions.map( + ({ key: source, label: sourceLabel, disabled }) => ( + + onChange({ + sources: { + ...settings.sources, + [source]: !settings.sources[source], + }, + }) + } + > + {sourceLabel} + + ), + )} + + )} +
+ )} +
+ ))} +
+ {answer ? ( + + ) : ( + + )} +
+ ); +}; + +const Thinking = (): ReactElement => ( + + + + + + Thinking + + +); + +export const AgentOnboardingScreen = ({ + query, + recentInterest, + isStandalone, + onSpawn, +}: { + query: string; + recentInterest?: UserInterest; + isStandalone?: boolean; + onSpawn?: (input: { query: string; settings: OnboardingSettings }) => void; +}): ReactElement => { + const shellHeight = useAgentShellHeight(isStandalone); + const fieldRef = useRef(null); + const transcriptRef = useRef(null); + const timeoutRef = useRef>(); + const [stage, setStage] = useState('angle'); + const [angles, setAngles] = useState([]); + const [excludes, setExcludes] = useState([]); + const [brief, setBrief] = useState(''); + const [settings, setSettings] = useState(baseSettings); + const [draft, setDraft] = useState(''); + const [picked, setPicked] = useState([]); + const [messages, setMessages] = useState([ + { id: nextId(), role: 'user', text: query }, + { + id: nextId(), + role: 'agent', + html: `

Got it. Before I start hunting, a couple of quick questions so we're on the same page — then I'll play the brief back to you.

First: what do you actually want out of this?

`, + control: { kind: 'chips', choices: angleChoices, multi: true }, + }, + ]); + + useEffect(() => () => clearTimeout(timeoutRef.current), []); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + const transcript = transcriptRef.current; + if (transcript) { + transcript.scrollTop = transcript.scrollHeight; + } + }); + return () => cancelAnimationFrame(frame); + }, [messages.length]); + + const isThinking = !!messages.at(-1)?.isPending; + + const resize = () => { + const field = fieldRef.current; + if (!field) { + return; + } + field.style.height = 'auto'; + field.style.height = `${Math.min(field.scrollHeight, maxFieldHeight)}px`; + }; + + const answerCurrent = (answer: string) => + setMessages((current) => + current.map((message, index) => + index === current.length - 1 && message.control + ? { ...message, answer } + : message, + ), + ); + + const say = ( + userText: string | undefined, + next: Message, + nextStage: Stage, + ) => { + const pendingId = nextId(); + setMessages((current) => [ + ...current, + ...(userText + ? [{ id: nextId(), role: 'user' as const, text: userText }] + : []), + { id: pendingId, role: 'agent', isPending: true }, + ]); + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setMessages((current) => + current.map((message) => + message.id === pendingId ? { ...next, id: pendingId } : message, + ), + ); + setPicked([]); + setStage(nextStage); + }, thinkMs); + }; + + const askExclude = (text: string) => + say( + text, + { + id: '', + role: 'agent', + html: `

Anything I should keep out of your way?

`, + control: { kind: 'chips', choices: excludeChoices, multi: true }, + }, + 'exclude', + ); + + const showBrief = (text: string | undefined, nextBrief: string) => { + setBrief(nextBrief); + say( + text, + { + id: '', + role: 'agent', + html: `

Here's how I'd frame the brief:

`, + control: { kind: 'brief' }, + }, + 'brief', + ); + }; + + const askSettings = (text: string) => + say( + text, + { + id: '', + role: 'agent', + html: `

Now, how should I run? I can reuse what you set up last time, go with the defaults, or we walk through it.

`, + control: { kind: 'actions', choices: settingsChoices }, + }, + 'settingsChoice', + ); + + const askCadence = (text: string) => + say( + text, + { + id: '', + role: 'agent', + html: `

How often do you want to hear from me? Whenever it matters lets me keep looking on my own and only reach out when something clears your bar.

`, + control: { kind: 'cadence' }, + }, + 'cadence', + ); + + const askFomo = (text: string) => + say( + text, + { + id: '', + role: 'agent', + html: `

How picky should I be? Slide left and you'll see more, including the borderline stuff. Slide right and only the very best gets through.

`, + control: { kind: 'fomo' }, + }, + 'fomo', + ); + + const askDelivery = (text: string) => + say( + text, + { + id: '', + role: 'agent', + html: `

Where should what I find land?

`, + control: { kind: 'delivery' }, + }, + 'delivery', + ); + + const askSources = (text: string) => + say( + text, + { + id: '', + role: 'agent', + html: `

Last one: where do I look? Web and GitHub discovery are coming next.

`, + control: { kind: 'sources' }, + }, + 'sources', + ); + + const showReview = (text: string, fromRecent?: boolean) => + say( + text, + { + id: '', + role: 'agent', + html: fromRecent + ? `

Loaded from ${escapeHtml( + recentInterest?.title ?? + recentInterest?.query ?? + 'your last agent', + )}. Here's the full picture — change anything before I start.

` + : `

Here's the full picture. Change anything before I start.

`, + control: { kind: 'review', fromRecent }, + }, + 'review', + ); + + const finish = () => { + onSpawn?.({ query: brief, settings }); + say( + 'Spawn it', + { + id: '', + role: 'agent', + html: `

Spawned. I'll run ${cadenceLabel( + settings.cadence, + ).toLowerCase()} and deliver to your ${deliveryLabel( + settings.outputModes, + )}. First pass is starting now — I'll only ping you when something clears your bar.

`, + control: { kind: 'done' }, + }, + 'done', + ); + }; + + const answerChips = (values: string[]) => { + if (stage === 'angle') { + const text = labelsFor(angleChoices, values).join(', '); + answerCurrent(text); + setAngles(values); + askExclude(text); + return; + } + const text = labelsFor(excludeChoices, values).join(', '); + answerCurrent(text); + setExcludes(values); + showBrief(text, buildBrief({ query, angles, excludes: values })); + }; + + const answerSettingsChoice = (value: string) => { + const label = + settingsChoices.find((choice) => choice.value === value)?.label ?? value; + answerCurrent(label); + if (value === 'recent' && recentInterest) { + setSettings(settingsFromInterest(recentInterest)); + showReview(label, true); + return; + } + if (value === 'defaults') { + setSettings(baseSettings); + showReview(label); + return; + } + askCadence(label); + }; + + const confirmBrief = () => { + answerCurrent('Looks right'); + askSettings('Looks right'); + }; + + const confirmCadence = () => { + const text = cadenceLabel(settings.cadence); + answerCurrent(text); + askFomo(text); + }; + + const confirmFomo = () => { + const text = fomoLabel(settings.fomoThreshold ?? 0.5); + answerCurrent(text); + askDelivery(text); + }; + + const confirmDelivery = () => { + const text = deliveryLabel(settings.outputModes); + answerCurrent(text); + askSources(text); + }; + + const confirmSources = () => { + const text = sourcesLabel(settings.sources); + answerCurrent(text); + showReview(text); + }; + + const confirmReview = () => { + answerCurrent('Spawned'); + finish(); + }; + + const advance = () => { + if (isThinking) { + return; + } + if (stage === 'angle') { + answerChips(picked.length ? picked : ['all']); + return; + } + if (stage === 'exclude') { + answerChips(picked.length ? picked : ['none']); + return; + } + if (stage === 'brief') { + confirmBrief(); + return; + } + if (stage === 'settingsChoice') { + answerSettingsChoice('stepwise'); + return; + } + if (stage === 'cadence') { + confirmCadence(); + return; + } + if (stage === 'fomo') { + confirmFomo(); + return; + } + if (stage === 'delivery') { + confirmDelivery(); + return; + } + if (stage === 'sources') { + confirmSources(); + return; + } + if (stage === 'review') { + confirmReview(); + } + }; + + const submitText = () => { + const trimmed = draft.trim(); + if (isThinking) { + return; + } + if (!trimmed) { + advance(); + return; + } + setDraft(''); + if (fieldRef.current) { + fieldRef.current.style.height = 'auto'; + } + + if (stage === 'angle') { + answerCurrent(trimmed); + askExclude(trimmed); + return; + } + if (stage === 'exclude') { + answerCurrent(trimmed); + showBrief( + trimmed, + `${buildBrief({ query, angles, excludes })} Also: ${trimmed}.`, + ); + return; + } + if (stage === 'brief') { + answerCurrent('Edited'); + showBrief(trimmed, trimmed); + return; + } + if (stage === 'done') { + say( + trimmed, + { + id: '', + role: 'agent', + html: `

Noted. This surface is a mockup, so nothing actually ran.

`, + }, + 'done', + ); + return; + } + + say( + trimmed, + { + id: '', + role: 'agent', + html: `

Noted — I'll fold that into the brief. Pick an option above to keep going.

`, + }, + stage, + ); + }; + + const renderControl = (message: Message): ReactNode => { + const { control, answer } = message; + if (!control) { + return null; + } + + if (control.kind === 'chips') { + return ( + + setPicked((current) => + current.includes(value) + ? current.filter((item) => item !== value) + : [...current, value], + ) + } + onAnswer={answerChips} + /> + ); + } + + if (control.kind === 'actions') { + return ( + + ); + } + + if (control.kind === 'brief') { + return ( + { + setDraft(brief); + fieldRef.current?.focus(); + }} + /> + ); + } + + if (control.kind === 'cadence') { + return ( + + setSettings((current) => ({ ...current, cadence })) + } + onConfirm={confirmCadence} + /> + ); + } + + if (control.kind === 'fomo') { + return ( + + setSettings((current) => ({ ...current, fomoThreshold })) + } + onConfirm={confirmFomo} + /> + ); + } + + if (control.kind === 'delivery') { + return ( + + setSettings((current) => ({ + ...current, + outputModes: { ...current.outputModes, ...modes }, + })) + } + onConfirm={confirmDelivery} + /> + ); + } + + if (control.kind === 'sources') { + return ( + + setSettings((current) => ({ + ...current, + sources: { ...current.sources, ...sources }, + })) + } + onConfirm={confirmSources} + /> + ); + } + + if (control.kind === 'review') { + return ( + + setSettings((current) => ({ ...current, ...next })) + } + onConfirm={confirmReview} + /> + ); + } + + return ( + + + + + ); + }; + + const placeholder = (() => { + if (stage === 'brief') { + return 'Rewrite the brief in your own words…'; + } + if (stage === 'done') { + return 'Tell it what to do next…'; + } + return 'Or type your own answer…'; + })(); + + return ( + + + + New agent + + + {query || 'Setting up'} + + + Mockup + + + +
+ +
+ + {messages.map((message) => { + if (message.role === 'user') { + return ( + +
+ + {message.text} + +
+
+ ); + } + + return ( + + {message.isPending && } + {message.html && ( + + )} + {message.control && ( + + {renderControl(message)} + + )} + + ); + })} +
+
+
+ +
+ + +