diff --git a/packages/shared/src/features/interests/AgentContext.tsx b/packages/shared/src/features/interests/AgentContext.tsx index 5d4186bc00..b220c8102d 100644 --- a/packages/shared/src/features/interests/AgentContext.tsx +++ b/packages/shared/src/features/interests/AgentContext.tsx @@ -17,10 +17,13 @@ import type { import { InterestRunStatus, InterestRunTrigger, + UserInterestOnboardingStep, UserInterestStatus, } from '../../graphql/interests'; import { useSendInterestCommand } from './hooks/useSendInterestCommand'; import { useUpdateInterest } from './hooks/useUpdateInterest'; +import { useCompleteInterestOnboarding } from './hooks/useCompleteInterestOnboarding'; +import { useConfirmInterestBrief } from './hooks/useConfirmInterestBrief'; import { useToastNotification } from '../../hooks/useToastNotification'; import { useAuthContext } from '../../contexts/AuthContext'; import { @@ -30,7 +33,12 @@ import { } from './queries'; import { generateQueryKey, RequestKey } from '../../lib/query'; import type { Post } from '../../graphql/posts'; -import type { AgentAttachment, AgentBlock, AgentMessage } from './chat'; +import type { + AgentAttachment, + AgentBlock, + AgentMessage, + AgentQuestionBlock, +} from './chat'; import { promptWithContext, restoreCommandText } from './chat'; import type { AgentFeedItem } from './hooks/useAgentFeed'; @@ -82,6 +90,7 @@ type RunCommandArgs = { label?: string; targetId?: string; attachments?: AgentAttachment[]; + questionId?: string; onComplete?: () => void; }; @@ -111,6 +120,27 @@ type AgentContextValue = { clearDraft: () => void; update: (data: UpdateInterestInput) => void; isUpdating: boolean; + isOnboarding: boolean; + onboardingStep?: UserInterestOnboardingStep | null; + /** The question at the tail of the transcript, still waiting on an answer. */ + activeQuestion?: AgentQuestionBlock; + answerQuestion: (args: { text: string; questionId: string }) => void; + /** Selection for the open question, shared so Enter and the chips agree. */ + pendingAnswer: string[]; + togglePendingAnswer: (value: string) => void; + /** The review card is open and waiting to be confirmed. */ + isReviewOpen: boolean; + completeOnboarding: () => void; + isCompleting: boolean; + /** The brief is on screen and can still be rewritten. */ + isBriefOpen: boolean; + confirmBrief: (brief?: string) => void; + isConfirmingBrief: boolean; + /** + * Accepts whichever onboarding step is open. Shared by the composer and the + * workspace-wide Enter, so both do exactly the same thing. + */ + advanceOnboarding: () => boolean; activity: AgentActivityItem[]; messages: AgentMessage[]; isHistoryPending: boolean; @@ -183,6 +213,15 @@ const mapServerBlocks = ( return acc; } + if ( + block.type === 'question' || + block.type === 'brief' || + block.type === 'review' + ) { + acc.push(block); + return acc; + } + if (block.type === 'picks') { const posts = resolvePosts(block.postIds, postsById); @@ -305,6 +344,10 @@ export const AgentProvider = ({ const { displayToast } = useToastNotification(); const { sendCommand } = useSendInterestCommand(id); const { isUpdating, updateInterest } = useUpdateInterest(id); + const { isCompleting, completeOnboarding: runCompleteOnboarding } = + useCompleteInterestOnboarding(id); + const { isConfirmingBrief, confirmBrief: runConfirmBrief } = + useConfirmInterestBrief(id); const queryClient = useQueryClient(); const [demoMessages, setDemoMessages] = useState(initialMessages); @@ -513,6 +556,26 @@ export const AgentProvider = ({ ); }, [findings, isDemo, turns]); + const isOnboarding = interest?.status === UserInterestStatus.Onboarding; + // Live only at the tail: every earlier question already has its answer sitting + // under it, so the flow never has two open at once. + const lastMessage = messages[messages.length - 1]; + const activeQuestion = + isOnboarding && lastMessage?.role === 'agent' && !lastMessage.isPending + ? (lastMessage.blocks?.find((block) => block.type === 'question') as + | AgentQuestionBlock + | undefined) + : undefined; + + const isReviewOpen = + !!isOnboarding && + lastMessage?.role === 'agent' && + !lastMessage.isPending && + !!lastMessage.blocks?.some((block) => block.type === 'review'); + + const isBriefOpen = + interest?.onboardingStep === UserInterestOnboardingStep.Brief; + const serverPending = historyTurns.some(isRunPending); const echoPending = unresolvedEchoes.some((echo) => echo.state !== 'error'); const isWorking = isDemo @@ -549,6 +612,7 @@ export const AgentProvider = ({ label, targetId, attachments: pointedAt, + questionId, onComplete, }: RunCommandArgs) => { workingRef.current = true; @@ -614,7 +678,7 @@ export const AgentProvider = ({ }, ]); - sendCommand({ text: prompt }) + sendCommand({ text: prompt, questionId }) .then(() => { setEchoes((current) => current.map((echo) => @@ -678,6 +742,90 @@ export const AgentProvider = ({ [interest, isDemo, sendCommand], ); + // Seeded from the question's own preselection and reset when the question + // changes, so the chips and the composer's Enter always submit the same set. + const [pendingAnswer, setPendingAnswer] = useState([]); + const openQuestionId = activeQuestion?.questionId; + + useEffect(() => { + setPendingAnswer(activeQuestion?.selected ?? []); + // Keyed on the question, not the block: re-renders must not wipe a choice. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openQuestionId]); + + const togglePendingAnswer = useCallback( + (value: string) => + setPendingAnswer((current) => { + if (!activeQuestion?.multi) { + return [value]; + } + + return current.includes(value) + ? current.filter((item) => item !== value) + : [...current, value]; + }), + [activeQuestion?.multi], + ); + + const confirmBrief = useCallback( + (brief?: string) => { + runConfirmBrief(brief).catch(() => undefined); + }, + [runConfirmBrief], + ); + + const completeOnboarding = useCallback(() => { + runCompleteOnboarding().catch(() => undefined); + }, [runCompleteOnboarding]); + + const answerQuestion = useCallback( + ({ text, questionId }: { text: string; questionId: string }) => + runCommand({ text, questionId, label: text }), + [runCommand], + ); + + const advanceOnboarding = useCallback((): boolean => { + if (activeQuestion) { + const picked = (activeQuestion.choices ?? []) + .filter(({ value }) => pendingAnswer.includes(value)) + .map(({ label }) => label) + .join(', '); + + if (!picked) { + return false; + } + + answerQuestion({ + text: picked, + questionId: activeQuestion.questionId, + }); + + return true; + } + + if (isBriefOpen) { + confirmBrief(); + + return true; + } + + if (isReviewOpen) { + completeOnboarding(); + + return true; + } + + return false; + }, [ + activeQuestion, + answerQuestion, + completeOnboarding, + confirmBrief, + isBriefOpen, + isReviewOpen, + pendingAnswer, + ]); + const attachContext = useCallback((attachment: AgentAttachment) => { setAttachments((current) => current.some(({ id: existing }) => existing === attachment.id) @@ -834,6 +982,19 @@ export const AgentProvider = ({ clearDraft: () => setDraft(undefined), update, isUpdating, + isOnboarding: !!isOnboarding, + onboardingStep: interest?.onboardingStep, + activeQuestion, + answerQuestion, + pendingAnswer, + togglePendingAnswer, + isReviewOpen, + completeOnboarding, + isCompleting, + isBriefOpen, + confirmBrief, + isConfirmingBrief, + advanceOnboarding, activity, messages, isHistoryPending: historyQuery.isPending, @@ -881,6 +1042,18 @@ export const AgentProvider = ({ id, interest, isDemo, + isOnboarding, + activeQuestion, + answerQuestion, + pendingAnswer, + togglePendingAnswer, + isReviewOpen, + completeOnboarding, + isCompleting, + isBriefOpen, + confirmBrief, + isConfirmingBrief, + advanceOnboarding, isSettingsOpen, isUpdating, isWorking, diff --git a/packages/shared/src/features/interests/attachments.ts b/packages/shared/src/features/interests/attachments.ts index 8bd2b6f662..c98e19f956 100644 --- a/packages/shared/src/features/interests/attachments.ts +++ b/packages/shared/src/features/interests/attachments.ts @@ -1,6 +1,7 @@ import type { Post } from '../../graphql/posts'; import type { AgentActivityItem, AgentContentTarget } from './AgentContext'; import type { AgentAttachment, AgentMessage } from './chat'; +import { isPostsBlock } from './chat'; // Ids match the strings the content tabs use, so a post open in the panel and // the same post in the transcript dedupe to one entry. @@ -74,7 +75,7 @@ const transcriptPosts = (messages: AgentMessage[]): Post[] => .slice() .reverse() .flatMap(({ blocks }) => blocks ?? []) - .flatMap((block) => (block.type === 'text' ? [] : block.posts)); + .flatMap((block) => (isPostsBlock(block) ? block.posts : [])); export const mentionCandidates = ({ openContent, diff --git a/packages/shared/src/features/interests/chat.ts b/packages/shared/src/features/interests/chat.ts index f6d0fa7346..b0a755391a 100644 --- a/packages/shared/src/features/interests/chat.ts +++ b/packages/shared/src/features/interests/chat.ts @@ -1,14 +1,28 @@ import type { Post } from '../../graphql/posts'; import type { + InterestQuestionChoice, InterestTurn, InterestTurnRelationship, } from '../../graphql/interests'; +export type AgentQuestionBlock = { + type: 'question'; + questionId: string; + html: string; + input: 'chips' | 'text'; + multi?: boolean; + choices?: InterestQuestionChoice[]; + selected?: string[]; +}; + export type AgentBlock = | { type: 'text'; html: string } | { type: 'posts'; caption?: string; posts: Post[] } | { type: 'picks'; caption?: string; posts: Post[] } - | { type: 'feedLink'; label: string; posts: Post[] }; + | { type: 'feedLink'; label: string; posts: Post[] } + | AgentQuestionBlock + | { type: 'brief'; html: string; brief: string } + | { type: 'review' }; export type AgentAttachment = { id: string; @@ -17,6 +31,13 @@ export type AgentAttachment = { detail?: string; }; +export type AgentPostsBlock = Extract; + +// Blocks that carry posts. A type guard rather than "not text", so a new block +// type is excluded by default instead of crashing whatever reads `.posts`. +export const isPostsBlock = (block: AgentBlock): block is AgentPostsBlock => + block.type === 'posts' || block.type === 'picks' || block.type === 'feedLink'; + export type AgentMessage = { id: string; role: 'user' | 'agent'; diff --git a/packages/shared/src/features/interests/components/AgentBriefBlock.tsx b/packages/shared/src/features/interests/components/AgentBriefBlock.tsx new file mode 100644 index 0000000000..b7b24a5a19 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentBriefBlock.tsx @@ -0,0 +1,115 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +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 { transcriptProse } from '../prose'; +import { useAgent } from '../AgentContext'; + +export const AgentBriefBlock = ({ + html, + brief, +}: { + html: string; + brief: string; +}): ReactElement => { + const { interest, isBriefOpen, confirmBrief, isConfirmingBrief } = useAgent(); + const [draft, setDraft] = useState(null); + const isEditing = draft !== null; + // Reflects a rewrite immediately, rather than the snapshot the run stored. + const current = interest?.brief ?? brief; + + return ( + + + Here is the brief I will run against + + + {isEditing ? ( +