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)} + + )} + + ); + })} +
+
+
+ +
+ + +