Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 175 additions & 2 deletions packages/shared/src/features/interests/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';

Expand Down Expand Up @@ -82,6 +90,7 @@ type RunCommandArgs = {
label?: string;
targetId?: string;
attachments?: AgentAttachment[];
questionId?: string;
onComplete?: () => void;
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<AgentMessage[]>(initialMessages);
Expand Down Expand Up @@ -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 =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: activeQuestion and isReviewOpen are both gated on isOnboarding, isBriefOpen is not. If the API leaves onboardingStep at brief after completeInterestOnboarding — or on a paused/stopped agent — the global Enter keeps confirming the brief and the brief card keeps its action buttons on a live agent. Making it isOnboarding && interest?.onboardingStep === UserInterestOnboardingStep.Brief costs nothing and stops the client depending on the API nulling that column.

Reviewed by AI.

interest?.onboardingStep === UserInterestOnboardingStep.Brief;

const serverPending = historyTurns.some(isRunPending);
const echoPending = unresolvedEchoes.some((echo) => echo.state !== 'error');
const isWorking = isDemo
Expand Down Expand Up @@ -549,6 +612,7 @@ export const AgentProvider = ({
label,
targetId,
attachments: pointedAt,
questionId,
onComplete,
}: RunCommandArgs) => {
workingRef.current = true;
Expand Down Expand Up @@ -614,7 +678,7 @@ export const AgentProvider = ({
},
]);

sendCommand({ text: prompt })
sendCommand({ text: prompt, questionId })
.then(() => {
setEchoes((current) =>
current.map((echo) =>
Expand Down Expand Up @@ -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<string[]>([]);
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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/features/interests/attachments.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion packages/shared/src/features/interests/chat.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +31,13 @@ export type AgentAttachment = {
detail?: string;
};

export type AgentPostsBlock = Extract<AgentBlock, { posts: Post[] }>;

// 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';
Expand Down
Loading
Loading