Found things.
' }, + { type: 'picks', postIds: ['p1', 'gone'] }, + { type: 'feedLink', label: 'Open all 4 findings', count: 4 }, + ], + } as InterestTurn, + ], + findings: [feedItem('p1')], + }); + + await waitForHistory(agent, (current) => current.messages.length === 2); + + expect(agent.current.messages.map(({ role }) => role)).toEqual([ + 'user', + 'agent', + ]); + const blocks = agent.current.messages.at(-1)?.blocks ?? []; + expect(blocks[0]).toEqual({ type: 'text', html: 'Found things.
' }); + expect(blocks[1]).toMatchObject({ type: 'picks' }); + expect((blocks[1] as { posts: Post[] }).posts.map(({ id }) => id)).toEqual([ + 'p1', + ]); + expect(blocks[2]).toMatchObject({ type: 'feedLink' }); + }); + + it('shows a queued or running run as the working state', async () => { + const agent = mountLive({ + turns: [ + { + id: 'run-1', + role: 'agent', + createdAt: '2026-01-01T00:00:00Z', + status: 'running', + trigger: 'scheduled', + } as InterestTurn, + ], + }); + + await waitForHistory(agent, (current) => current.messages.length === 1); + + expect(agent.current.isWorking).toBe(true); + expect(agent.current.messages.at(-1)?.isPending).toBe(true); + }); + + it('pairs a failed run with the command it answers so it can be retried', async () => { + const agent = mountLive({ + turns: [ + { + id: 'fb-1', + role: 'user', + createdAt: '2026-01-01T00:00:00Z', + text: 'raise the bar', + }, + { + id: 'run-1', + role: 'agent', + createdAt: '2026-01-01T00:00:01Z', + status: 'failed', + trigger: 'command', + feedbackId: 'fb-1', + } as InterestTurn, + ], + }); + + await waitForHistory(agent, (current) => current.messages.length === 2); + + const reply = agent.current.messages.at(-1); + expect(reply?.isError).toBe(true); + expect(reply?.retryText).toBe('raise the bar'); + }); + + it('drops a quiet scheduled run but still answers a quiet command', async () => { + const agent = mountLive({ + turns: [ + { + id: 'run-quiet', + role: 'agent', + createdAt: '2026-01-01T00:00:00Z', + status: 'completed', + trigger: 'scheduled', + blocks: [], + } as InterestTurn, + { + id: 'run-answer', + role: 'agent', + createdAt: '2026-01-01T00:01:00Z', + status: 'completed', + trigger: 'command', + blocks: [], + } as InterestTurn, + ], + }); + + await waitForHistory(agent, (current) => current.messages.length === 1); + + expect(agent.current.messages.map(({ id }) => id)).toEqual(['run-answer']); + expect(agent.current.messages[0]?.blocks?.[0]).toMatchObject({ + type: 'text', + }); + }); + + it('echoes a sent command as pending until the history catches up', async () => { + const agent = mountLive({ send: () => Promise.resolve() }); + + await flushQueries(); + await act(async () => { + agent.current.runCommand({ text: 'raise the bar' }); + }); + + const reply = agent.current.messages.at(-1); + expect(agent.current.messages.at(-2)?.text).toBe('raise the bar'); + expect(reply?.isPending).toBe(true); + expect(reply?.blocks).toBeUndefined(); + expect(agent.current.isWorking).toBe(true); + }); + + it('reports a rejected command as an error the reader can retry', async () => { + const agent = mountLive({ send: () => Promise.reject(new Error('nope')) }); + + await flushQueries(); + await act(async () => { + agent.current.runCommand({ text: 'raise the bar' }); + }); + + const reply = agent.current.messages.at(-1); + + expect(reply?.isError).toBe(true); + expect(reply?.retryText).toBe('raise the bar'); + expect(agent.current.isWorking).toBe(false); + }); + + it('rolls the optimistic status back when the update is rejected', async () => { + jest.spyOn(updateHook, 'useUpdateInterest').mockReturnValue({ + isUpdating: false, + updateInterest: () => Promise.reject(new Error('nope')), + } as never); + const agent = mountLive({ + turns: [ + { + id: 'a1-spawn', + role: 'user', + createdAt: '2026-01-01T00:00:00Z', + text: 'zig', + }, + ], + }); + + await flushQueries(); + await act(async () => { + agent.current.update({ status: 'paused' as never }); + }); + + expect(agent.current.status).toBe('active'); + }); + + it('attaches the summary post to the run that wrote it and keeps quiet writers visible', async () => { + const agent = mountLive({ + turns: [ + { + id: 'run-1', + role: 'agent', + createdAt: '2026-01-01T00:00:00Z', + status: 'completed', + trigger: 'scheduled', + summaryPostId: 'sp-1', + blocks: [], + } as InterestTurn, + ], + posts: [ + { + id: 'sp-1', + title: 'Zig this week', + createdAt: '2026-01-01T00:02:00Z', + }, + ], + }); + + await waitForHistory(agent, (current) => current.messages.length === 1); + + expect(agent.current.messages[0].summaryPost).toMatchObject({ + id: 'sp-1', + title: 'Zig this week', + }); + expect(agent.current.summaryPosts).toHaveLength(1); + }); + + it('derives the activity log from history and findings', async () => { + const agent = mountLive({ + turns: [ + { + id: 'fb-1', + role: 'user', + createdAt: '2026-01-01T00:00:00Z', + text: 'raise the bar', + }, + { + id: 'run-1', + role: 'agent', + createdAt: '2026-01-01T00:01:00Z', + finishedAt: '2026-01-01T00:02:00Z', + status: 'completed', + trigger: 'command', + feedbackId: 'fb-1', + findingsAdded: 2, + summaryPostId: 'sp-1', + blocks: [{ type: 'text', html: 'Done.
' }], + } as InterestTurn, + ], + findings: [feedItem('p1')], + }); + + await waitForHistory(agent, (current) => current.activity.length === 4); + + expect(agent.current.activity.map(({ kind }) => kind).sort()).toEqual([ + 'command', + 'finding', + 'post', + 'run', + ]); + }); +}); + +describe('running commands', () => { + it('puts the prompt and a pending reply in the transcript', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'raise the bar' })); + + expect(agent.current.messages.map(({ role }) => role)).toEqual([ + 'user', + 'agent', + ]); + expect(agent.current.isWorking).toBe(true); + }); + + it('resolves the pending reply when the run finishes', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'raise the bar' })); + act(() => jest.advanceTimersByTime(3000)); + + expect(agent.current.isWorking).toBe(false); + expect(agent.current.messages.at(-1)?.isPending).toBeFalsy(); + expect(agent.current.messages.at(-1)?.blocks).toBeDefined(); + }); + + it('queues a second prompt rather than running two at once', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'first' })); + act(() => agent.current.runCommand({ text: 'second' })); + + expect(agent.current.queuedCommands.map(({ text }) => text)).toEqual([ + 'second', + ]); + }); + + it('starts the queued prompt once the first one lands', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'first' })); + act(() => agent.current.runCommand({ text: 'second' })); + act(() => jest.advanceTimersByTime(3000)); + + expect(agent.current.queuedCommands).toHaveLength(0); + expect(agent.current.isWorking).toBe(true); + expect(agent.current.workingLabel).toBe('second'); + }); + + // StrictMode re-runs state updaters, so one that schedules work as a side + // effect schedules it twice. + it('starts a queued prompt once, not twice, under StrictMode', () => { + const seen: { current: Agent } = { current: undefined as never }; + + const Probe = () => { + seen.current = useAgent(); + + return null; + }; + + render( +Done. Nothing new cleared your bar this run.
', + }); + } + + acc.push({ + id: turn.id, + role: 'agent', + at: turn.finishedAt ?? turn.createdAt, + isPending, + isScheduled: turn.trigger === InterestRunTrigger.Scheduled, + isError, + retryText, + blocks: isPending || isError ? undefined : blocks, + summaryPost: + !isPending && !isError && turn.summaryPostId + ? summaryPostsById.get(turn.summaryPostId) + : undefined, + }); + return acc; + }, []); +}; + +type CommandEcho = { + id: string; + text: string; + prompt: string; + attachments?: AgentAttachment[]; + sentAt: number; + state: 'sending' | 'sent' | 'error'; +}; + +const echoResolved = (echo: CommandEcho, turns: InterestTurn[]): boolean => + turns.some( + (turn) => + turn.role === 'user' && + turn.text === echo.prompt && + new Date(turn.createdAt).getTime() >= echo.sentAt - echoMatchWindowMs, + ); + +export const AgentProvider = ({ + id, + interest, + isDemo, + initialMessages = [], + findings = [], + posts = [], + children, +}: { + id: string; + interest?: UserInterest; + isDemo: boolean; + initialMessages?: AgentMessage[]; + findings?: AgentFeedItem[]; + posts?: AgentSummaryPost[]; + children: ReactNode; +}): ReactElement => { + const { user } = useAuthContext(); + const { displayToast } = useToastNotification(); + const { sendCommand } = useSendInterestCommand(id); + const { isUpdating, updateInterest } = useUpdateInterest(id); + const queryClient = useQueryClient(); + const [demoMessages, setDemoMessages] = + useStateSent. This surface is a demo, so nothing actually ran.
', + }, + ], + } + : message, + ), + ); + onComplete?.(); + }, demoWorkDurationMs); + + return; + } + + const echoId = nextId(); + const prompt = promptWithContext(text, pointedAt ?? []); + setEchoes((current) => [ + ...current, + { + id: echoId, + text, + prompt, + attachments: pointedAt, + sentAt: Date.now(), + state: 'sending', + }, + ]); + + sendCommand({ text: prompt }) + .then(() => { + setEchoes((current) => + current.map((echo) => + echo.id === echoId ? { ...echo, state: 'sent' } : echo, + ), + ); + onComplete?.(); + }) + .catch(() => { + workingRef.current = false; + setEchoes((current) => + current.map((echo) => + echo.id === echoId ? { ...echo, state: 'error' } : echo, + ), + ); + }); + }, + [displayToast, interest, isDemo, sendCommand], + ); + + // Draining in an effect, not inside the updater that removes the entry: + // StrictMode re-runs updaters, which sent every queued prompt twice. + useEffect(() => { + if (isWorking || !queuedCommands.length) { + return; + } + + const [next] = queuedCommands; + + setQueuedCommands((current) => current.slice(1)); + startRun(next.args); + }, [isWorking, queuedCommands, startRun]); + + const runCommand = useCallback( + (args: RunCommandArgs) => { + if (args.attachments?.length) { + setAttachments([]); + } + + if (workingRef.current) { + setQueuedCommands((current) => [ + ...current, + { id: `${Date.now()}-${current.length}`, args }, + ]); + return; + } + + startRun(args); + }, + [startRun], + ); + + const sendFeedback = useCallback( + async (text: string) => { + if (isDemo || !interest) { + return; + } + + await sendCommand({ text, triggerRun: false }); + }, + [interest, isDemo, sendCommand], + ); + + const attachContext = useCallback((attachment: AgentAttachment) => { + setAttachments((current) => + current.some(({ id: existing }) => existing === attachment.id) + ? current + : [...current, attachment], + ); + composerRef.current?.focus(); + }, []); + + const detachContext = useCallback( + (attachmentId: string) => + setAttachments((current) => + current.filter(({ id: existing }) => existing !== attachmentId), + ), + [], + ); + + const removeQueuedCommand = useCallback( + (queuedId: string) => + setQueuedCommands((current) => + current.filter((command) => command.id !== queuedId), + ), + [], + ); + + const openContentTarget = useCallback((target: AgentContentTarget) => { + const targetId = contentTargetId(target); + + setContent(({ items }) => ({ + items: items.some((item) => contentTargetId(item) === targetId) + ? items + : [...items, target], + activeId: targetId, + })); + }, []); + + const focusContent = useCallback( + (targetId: string) => + setContent(({ items }) => ({ items, activeId: targetId })), + [], + ); + + const closeContent = useCallback((targetId: string) => { + setContent(({ items, activeId }) => { + const index = items.findIndex( + (item) => contentTargetId(item) === targetId, + ); + + if (index < 0) { + return { items, activeId }; + } + + const next = items.filter((_, position) => position !== index); + const successor = next[index] ?? next[next.length - 1]; + + return { + items: next, + activeId: + activeId === targetId && successor + ? contentTargetId(successor) + : activeId, + }; + }); + }, []); + + const closeAllContent = useCallback(() => setContent({ items: [] }), []); + + const update = useCallback( + (data: UpdateInterestInput) => { + if (data.status) { + setStatusOverride(data.status); + } + + if (isDemo || !interest) { + return; + } + + updateInterest(data).catch(() => { + if (data.status) { + setStatusOverride(undefined); + } + }); + }, + [interest, isDemo, updateInterest], + ); + + // The override only bridges the gap until the refetched interest confirms + // it; holding it longer would pin the UI to a stale optimistic value. + useEffect(() => { + if (statusOverride && interest?.status === statusOverride) { + setStatusOverride(undefined); + } + }, [interest?.status, statusOverride]); + + const value = useMemoDaily run — kept 2.
' }, + { type: 'posts', caption: 'The two:', posts: [post('p1', 'Zig 0.15')] }, + { + type: 'picks', + caption: 'Runners-up:', + posts: [post('p2', 'Ghostty is open source')], + }, + ], +}; + +const renderTranscript = () => + render( +');
+ expect(textToCopy).not.toContain('');
+ });
+});
+
+describe('a post the agent found', () => {
+ it('can be passed on from the row it sits in', () => {
+ renderTranscript();
+
+ expect(screen.getByLabelText('Share: Zig 0.15')).toBeInTheDocument();
+ expect(screen.getByLabelText('Add to chat: Zig 0.15')).toBeInTheDocument();
+ });
+
+ it('offers its link without going through the sheet', () => {
+ const copyLink = jest.fn();
+ jest.spyOn(copy, 'useCopyLink').mockReturnValue([false, copyLink] as never);
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Copy link: Zig 0.15'));
+
+ expect(copyLink).toHaveBeenCalled();
+ });
+
+ it('puts the outward actions before the one for the next prompt', () => {
+ renderTranscript();
+
+ // By document-order index, not ancestry: each button has its own tooltip
+ // wrapper, so the cluster has no shared parent to walk.
+ const buttons = Array.from(document.querySelectorAll('button'));
+ const positionOf = (label: string) =>
+ buttons.indexOf(screen.getByLabelText(label) as HTMLButtonElement);
+
+ expect(positionOf('Copy link: Zig 0.15')).toBeLessThan(
+ positionOf('Share: Zig 0.15'),
+ );
+ expect(positionOf('Share: Zig 0.15')).toBeLessThan(
+ positionOf('Add to chat: Zig 0.15'),
+ );
+ });
+
+ it('goes to the app share modal rather than a sheet of its own', () => {
+ const openModal = jest.fn();
+
+ jest
+ .spyOn(lazyModal, 'useLazyModal')
+ .mockReturnValue({ openModal, closeModal: jest.fn() } as never);
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Share: Zig 0.15'));
+
+ expect(openModal).toHaveBeenCalledWith({
+ type: LazyModal.Share,
+ props: {
+ post: expect.objectContaining({ id: 'p1' }),
+ origin: Origin.Agent,
+ },
+ });
+ });
+});
+
+describe('the reply actions', () => {
+ it('are on screen without being hunted for', () => {
+ renderTranscript();
+
+ const row = screen.getByLabelText('Copy reply').parentElement;
+
+ expect(row).not.toHaveClass('opacity-0');
+ ['Copy reply', 'Good reply', 'Bad reply', 'Share reply'].forEach((label) =>
+ expect(screen.getByLabelText(label)).toBeInTheDocument(),
+ );
+ });
+
+ it('put share at the end of the row', () => {
+ renderTranscript();
+
+ const row = screen.getByLabelText('Copy reply').parentElement;
+ const labels = Array.from(row?.querySelectorAll('button') ?? []).map(
+ (button) => button.getAttribute('aria-label'),
+ );
+
+ expect(labels[labels.length - 1]).toBe('Share reply');
+ });
+});
+
+describe('sharing a reply', () => {
+ it('says what the link actually opens', () => {
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Share reply'));
+
+ expect(
+ screen.getByText(/opens this agent with its topic ready to run/),
+ ).toBeInTheDocument();
+ });
+
+ it('offers the two things worth sending, and no bespoke control', () => {
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Share reply'));
+ const dialog = screen.getByRole('dialog');
+
+ expect(
+ within(dialog).getByRole('button', { name: /Copy link/ }),
+ ).toBeInTheDocument();
+ expect(
+ within(dialog).getByRole('button', { name: /Copy image/ }),
+ ).toBeInTheDocument();
+ });
+
+ it('shows the reply it is about, citations and all', () => {
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Share reply'));
+ const dialog = screen.getByRole('dialog');
+
+ expect(dialog).toHaveTextContent('Daily run');
+ expect(
+ within(dialog).getByRole('link', { name: 'Zig 0.15' }),
+ ).toHaveAttribute('href', 'https://app.daily.dev/posts/p1');
+ });
+
+ it('signs the card, so a reply that travels says whose it is', () => {
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Share reply'));
+ const dialog = screen.getByRole('dialog');
+
+ expect(dialog).toHaveTextContent('Agent');
+ expect(dialog.querySelector('.agent-share-card')).toBeInTheDocument();
+ // An element rather than a `::before`, which the clone taken for the image
+ // cannot carry.
+ expect(dialog.querySelector('.agent-share-glow')).toBeInTheDocument();
+ });
+
+ it('keeps the link working alongside it', () => {
+ renderTranscript();
+
+ fireEvent.click(screen.getByLabelText('Share reply'));
+
+ expect(
+ within(screen.getByRole('dialog')).getByRole('button', {
+ name: /Copy link/,
+ }),
+ ).toBeEnabled();
+ });
+});
diff --git a/packages/shared/src/features/interests/components/AgentChatSection.tsx b/packages/shared/src/features/interests/components/AgentChatSection.tsx
new file mode 100644
index 00000000000..3074e11140e
--- /dev/null
+++ b/packages/shared/src/features/interests/components/AgentChatSection.tsx
@@ -0,0 +1,436 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useState } from 'react';
+import Markdown from '../../../components/Markdown';
+import { Tooltip } from '../../../components/tooltip/Tooltip';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { FlexCol, FlexRow } from '../../../components/utilities';
+import {
+ Button,
+ ButtonColor,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import {
+ BulletListIcon,
+ CopyIcon,
+ DownvoteIcon,
+ FeatherIcon,
+ ShareIcon,
+ MiniCloseIcon,
+ TimerIcon,
+ UpvoteIcon,
+ VIcon,
+ WarningIcon,
+} from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import { useCopyText } from '../../../hooks/useCopy';
+import { DateFormat } from '../../../components/utilities/DateFormat';
+import { TimeFormatType } from '../../../lib/dateFormat';
+import type { Post } from '../../../graphql/posts';
+import type { AgentBlock, AgentMessage } from '../chat';
+import { useAgent } from '../AgentContext';
+import { transcriptProse } from '../prose';
+import { messageAsMarkdown, messageAsText } from '../replyText';
+import { AgentShareReplyModal } from './AgentShareReplyModal';
+import { feedAttachment, quoteAttachment } from '../attachments';
+import { AgentPickList } from './AgentPickList';
+import { AgentAttachmentChip } from './AgentAttachmentChip';
+import { addToChatFloat, AgentAddToChatButton } from './AgentAddToChatButton';
+import { AgentThinkingStrip } from './AgentThinkingStrip';
+import { AgentPostCard } from './AgentPostCard';
+import { AgentEmbedCard } from './blocks/AgentEmbedCard';
+
+const BlockRenderer = ({
+ block,
+ onPostClick,
+ onFeedClick,
+ activePostId,
+}: {
+ block: AgentBlock;
+ onPostClick: (post: Post) => void;
+ onFeedClick: (label: string, posts: Post[]) => void;
+ activePostId?: string;
+}): ReactElement => {
+ if (block.type === 'text') {
+ return