diff --git a/packages/shared/__tests__/helpers/media.ts b/packages/shared/__tests__/helpers/media.ts new file mode 100644 index 00000000000..5fb5b8a2e15 --- /dev/null +++ b/packages/shared/__tests__/helpers/media.ts @@ -0,0 +1,28 @@ +// The global stub in `setup.ts` never matches and carries only the legacy +// `addListener`, which libraries calling `addEventListener` blow up on. +export const mockMatchMedia = ( + matches: (query: string) => boolean = () => false, +): void => { + (global.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + media: query, + matches: matches(query), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + onchange: null, + })); +}; + +export const laptopQuery = '(min-width: 1020px)'; +export const noHoverQuery = '(hover: none)'; + +// A 1020px window matches every width breakpoint at or below laptop, so +// answering only the laptop query reports a desktop that is not a tablet. +export const mockDesktop = (): void => + mockMatchMedia((query) => { + const [, min] = /min-width:\s*(\d+)px/.exec(query) ?? []; + + return !!min && Number(min) <= 1020; + }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 0813f35eda4..1cab59ec9b7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", "edge-aura": "0.6.0", diff --git a/packages/shared/src/components/cards/article/ArticleList.spec.tsx b/packages/shared/src/components/cards/article/ArticleList.spec.tsx new file mode 100644 index 00000000000..b81071738c8 --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleList.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import post from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { mockDesktop } from '../../../../__tests__/helpers/media'; +import { ArticleList } from './ArticleList'; + +jest.mock('next/router', () => ({ useRouter: jest.fn() })); + +const renderCard = (isNarrow?: boolean) => + render( + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + mockDesktop(); + jest + .mocked(useRouter) + .mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter)); +}); + +describe('ArticleList in a narrow column', () => { + it('stacks the cover under the title', () => { + renderCard(true); + + // Found by alt text: the testid sits on the wrapper, not on the image. + const cover = screen.getByAltText('Post Cover image'); + + expect(cover).toHaveClass('!w-full'); + expect(cover).toHaveClass('self-stretch'); + }); + + it('drops the gutter that separated the title from the cover beside it', () => { + renderCard(true); + + expect(screen.getByText(post.title as string).closest('.mr-4')).toBeNull(); + }); + + it('leaves the wide card exactly as it was', () => { + renderCard(); + + const cover = screen.getByAltText('Post Cover image'); + + expect(cover).not.toHaveClass('!w-full'); + expect( + screen.getByText(post.title as string).closest('.mr-4'), + ).not.toBeNull(); + }); +}); diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index afeaa5c459d..1d99abf8fc1 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -45,7 +45,14 @@ export const ArticleList = forwardRef(function ArticleList( domProps = {}, onShare, eagerLoadImage = false, - }: PostCardProps, + isNarrow = false, + }: PostCardProps & { + /** + * Takes the phone's stacked layout at any viewport, for a card in a dragged + * column the window's breakpoints know nothing about. + */ + isNarrow?: boolean; + }, ref: Ref, ): ReactElement { const { className, style } = domProps; @@ -55,6 +62,7 @@ export const ArticleList = forwardRef(function ArticleList( const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); + const isStacked = isMobile || isNarrow; const { showFeedback } = usePostFeedback({ post }); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const isFeedPreview = useFeedPreviewMode(); @@ -165,8 +173,13 @@ export const ArticleList = forwardRef(function ArticleList( )} - -
+ +
- {!isMobile && actionButtons} + {!isStacked && actionButtons}
- {isMobile && actionButtons} + {isStacked && actionButtons} {children} )} diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index d2a5b39c549..1066fcd54ed 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -219,6 +219,7 @@ export const notificationTypeTheme: Partial> = [NotificationType.UserAwardThanks]: 'text-brand-default', [NotificationType.BriefingReady]: 'text-brand-default', [NotificationType.DigestReady]: 'text-brand-default', + [NotificationType.InterestContentBatch]: 'text-brand-default', [NotificationType.UserFollow]: 'text-brand-default', }; diff --git a/packages/shared/src/features/giveback/useGivebackMotion.ts b/packages/shared/src/features/giveback/useGivebackMotion.ts index 5afeefbd846..25ce5d5d458 100644 --- a/packages/shared/src/features/giveback/useGivebackMotion.ts +++ b/packages/shared/src/features/giveback/useGivebackMotion.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; -const usePrefersReducedMotion = (): boolean => { +export const usePrefersReducedMotion = (): boolean => { const [reduced, setReduced] = useState(false); useEffect(() => { diff --git a/packages/shared/src/features/interests/AgentContext.spec.tsx b/packages/shared/src/features/interests/AgentContext.spec.tsx new file mode 100644 index 00000000000..b2cf8c3815b --- /dev/null +++ b/packages/shared/src/features/interests/AgentContext.spec.tsx @@ -0,0 +1,695 @@ +import React from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import defaultUser from '../../../__tests__/fixture/loggedUser'; +import type { Post } from '../../graphql/posts'; +import type { InterestTurn } from '../../graphql/interests'; +import type { AgentMessage } from './chat'; +import type { AgentFeedItem } from './hooks/useAgentFeed'; +import * as command from './hooks/useSendInterestCommand'; +import * as updateHook from './hooks/useUpdateInterest'; +import * as queries from './queries'; +import { AgentProvider, contentTargetId, useAgent } from './AgentContext'; + +const post = (id: string): Post => ({ id, title: `Post ${id}` } as Post); + +const attachment = (id: string) => ({ + id, + kind: 'post' as const, + label: `Label ${id}`, +}); + +type Agent = ReturnType; + +const mountAgent = () => { + const seen: { current: Agent } = { current: undefined as never }; + + const Probe = () => { + seen.current = useAgent(); + + return null; + }; + + render( + + + + + , + ); + + return seen; +}; + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); + +describe('contentTargetId', () => { + it('keys posts and feeds by what they hold, and panes by name', () => { + expect(contentTargetId({ type: 'post', post: post('p1') })).toBe('post:p1'); + expect( + contentTargetId({ type: 'feed', label: 'Findings', posts: [] }), + ).toBe('feed:Findings'); + expect(contentTargetId({ type: 'activity' })).toBe('activity'); + }); +}); + +describe('attachments', () => { + it('adds what a button points at', () => { + const agent = mountAgent(); + + act(() => agent.current.attachContext(attachment('post:a'))); + + expect(agent.current.attachments).toHaveLength(1); + }); + + it('adds the same thing twice as one chip, not two', () => { + const agent = mountAgent(); + + act(() => agent.current.attachContext(attachment('post:a'))); + act(() => agent.current.attachContext(attachment('post:a'))); + + expect(agent.current.attachments).toHaveLength(1); + }); + + it('removes one without disturbing the others', () => { + const agent = mountAgent(); + + act(() => agent.current.attachContext(attachment('post:a'))); + act(() => agent.current.attachContext(attachment('post:b'))); + act(() => agent.current.detachContext('post:a')); + + expect(agent.current.attachments.map(({ id }) => id)).toEqual(['post:b']); + }); + + it('lets the references leave with the prompt they were attached to', () => { + const agent = mountAgent(); + + act(() => agent.current.attachContext(attachment('post:a'))); + act(() => + agent.current.runCommand({ + text: 'why this one', + attachments: agent.current.attachments, + }), + ); + + expect(agent.current.attachments).toHaveLength(0); + }); +}); + +describe('an opening transcript that arrives after mount', () => { + const mountWithLateOpening = () => { + const seen: { current: Agent } = { current: undefined as never }; + + const Probe = () => { + seen.current = useAgent(); + + return null; + }; + + const Host = ({ messages }: { messages: AgentMessage[] }) => ( + + + + + + ); + + const opening: AgentMessage[] = [ + { id: 'o1', role: 'user', at: '', text: 'Cool zig projects' }, + { id: 'o2', role: 'agent', at: '', blocks: [] }, + ]; + const view = render(); + + return { + seen, + opening, + rerender: (messages: AgentMessage[]) => + view.rerender(), + }; + }; + + it('is adopted, rather than leaving the page blank for good', () => { + const { seen, opening, rerender } = mountWithLateOpening(); + + expect(seen.current.messages).toHaveLength(0); + + act(() => rerender(opening)); + + expect(seen.current.messages.map(({ id }) => id)).toEqual(['o1', 'o2']); + }); + + it('never overwrites a transcript that already has turns in it', () => { + const { seen, opening, rerender } = mountWithLateOpening(); + + act(() => seen.current.runCommand({ text: 'raise the bar' })); + const before = seen.current.messages.length; + + act(() => rerender(opening)); + + expect(seen.current.messages).toHaveLength(before); + expect(seen.current.messages[0].text).toBe('raise the bar'); + }); +}); + +describe('the draft', () => { + it('carries text written from elsewhere on the screen', () => { + const agent = mountAgent(); + + act(() => agent.current.writeDraft('I marked that one down because ')); + + expect(agent.current.draft).toBe('I marked that one down because '); + }); + + it('can be cleared and written again', () => { + const agent = mountAgent(); + + act(() => agent.current.writeDraft('first')); + act(() => agent.current.clearDraft()); + + expect(agent.current.draft).toBeUndefined(); + + act(() => agent.current.writeDraft('first')); + + expect(agent.current.draft).toBe('first'); + }); +}); + +describe('content tabs', () => { + it('opens a tab and focuses it', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + + expect(agent.current.openContent).toHaveLength(1); + expect(agent.current.activeContentId).toBe('activity'); + }); + + it('re-opening something already open focuses it instead of duplicating it', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + act(() => agent.current.openContentTarget({ type: 'debug' })); + act(() => agent.current.openContentTarget({ type: 'activity' })); + + expect(agent.current.openContent).toHaveLength(2); + expect(agent.current.activeContentId).toBe('activity'); + }); + + it('moves to the tab that slid into the closed one’s place', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + act(() => agent.current.openContentTarget({ type: 'debug' })); + act(() => + agent.current.openContentTarget({ type: 'post', post: post('p1') }), + ); + act(() => agent.current.focusContent('debug')); + act(() => agent.current.closeContent('debug')); + + expect(agent.current.activeContentId).toBe('post:p1'); + }); + + it('falls back to the new last tab when the rightmost one closes', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + act(() => agent.current.openContentTarget({ type: 'debug' })); + act(() => agent.current.closeContent('debug')); + + expect(agent.current.activeContentId).toBe('activity'); + }); + + it('leaves the focus alone when a tab you were not on closes', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + act(() => agent.current.openContentTarget({ type: 'debug' })); + act(() => agent.current.closeContent('activity')); + + expect(agent.current.activeContentId).toBe('debug'); + }); + + it('ignores a request to close something that is not open', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + act(() => agent.current.closeContent('debug')); + + expect(agent.current.openContent).toHaveLength(1); + }); + + it('closes the lot', () => { + const agent = mountAgent(); + + act(() => agent.current.openContentTarget({ type: 'activity' })); + act(() => agent.current.openContentTarget({ type: 'debug' })); + act(() => agent.current.closeAllContent()); + + expect(agent.current.openContent).toHaveLength(0); + expect(agent.current.activeContent).toBeUndefined(); + }); +}); + +const feedItem = (id: string): AgentFeedItem => ({ + id: `f-${id}`, + post: post(id), + score: 0.9, + rationale: '', + createdAt: '2026-01-01T00:00:00Z', +}); + +/** + * The live transcript is the server's interest history plus a local echo for a + * command still in flight. A reply only ever comes from a persisted run — an + * earlier draft answered from a timer and reported findings the backend never + * accepted. + */ +describe('the live path', () => { + // The transcript arrives through a real query; fake timers starve its + // scheduling, so this block waits on real ones instead. + beforeEach(() => jest.useRealTimers()); + + const mountLive = ({ + send = () => Promise.resolve(), + turns = [] as InterestTurn[], + findings = [] as AgentFeedItem[], + posts = [] as { id: string; title: string; createdAt: string }[], + } = {}) => { + jest + .spyOn(command, 'useSendInterestCommand') + .mockReturnValue({ isSending: false, sendCommand: send } as never); + jest.spyOn(queries, 'interestHistoryQueryOptions').mockReturnValue({ + queryKey: ['history', 'a1'], + queryFn: async () => turns, + } as never); + + const seen: { current: Agent } = { current: undefined as never }; + + const Probe = () => { + seen.current = useAgent(); + + return null; + }; + + render( + + + + + , + ); + + return seen; + }; + + const flushQueries = async () => { + await act(async () => { + await Promise.resolve(); + }); + }; + + const waitForHistory = ( + agent: { current: Agent }, + predicate: (current: Agent) => boolean, + ) => waitFor(() => expect(predicate(agent.current)).toBe(true)); + + it('renders the server history, resolving picks against the findings it has', async () => { + const agent = mountLive({ + turns: [ + { + id: 'a1-spawn', + role: 'user', + createdAt: '2026-01-01T00:00:00Z', + text: 'zig', + }, + { + id: 'run-1', + role: 'agent', + createdAt: '2026-01-01T00:01:00Z', + status: 'completed', + trigger: 'spawn', + blocks: [ + { type: 'text', html: '

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( + + + + + + + , + ); + + act(() => seen.current.runCommand({ text: 'first' })); + act(() => seen.current.runCommand({ text: 'second' })); + act(() => jest.advanceTimersByTime(3000)); + + const sent = seen.current.messages.filter( + ({ role, text }) => role === 'user' && text === 'second', + ); + + expect(sent).toHaveLength(1); + }); + + it('lets a queued prompt be taken back before it starts', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'first' })); + act(() => agent.current.runCommand({ text: 'second' })); + act(() => + agent.current.removeQueuedCommand(agent.current.queuedCommands[0].id), + ); + act(() => jest.advanceTimersByTime(3000)); + + expect(agent.current.isWorking).toBe(false); + }); + + it('knows which target it is working on', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'why', targetId: 'post:p1' })); + + expect(agent.current.isTargetWorking('post:p1')).toBe(true); + expect(agent.current.isTargetWorking('post:p2')).toBe(false); + }); + + it('leaves nothing spinning once a queue has drained', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'first' })); + act(() => agent.current.runCommand({ text: 'second' })); + act(() => agent.current.runCommand({ text: 'third' })); + + // Each run resolves on its own timer, so the advances cannot be merged. + act(() => jest.advanceTimersByTime(3000)); + act(() => jest.advanceTimersByTime(3000)); + act(() => jest.advanceTimersByTime(3000)); + + expect(agent.current.isWorking).toBe(false); + expect(agent.current.queuedCommands).toHaveLength(0); + expect(agent.current.messages.filter(({ isPending }) => isPending)).toEqual( + [], + ); + expect( + agent.current.messages + .filter(({ role }) => role === 'user') + .map(({ text }) => text), + ).toEqual(['first', 'second', 'third']); + }); + + it('reflects a status switch without touching the transcript', () => { + const agent = mountAgent(); + + act(() => agent.current.runCommand({ text: 'raise the bar' })); + act(() => agent.current.update({ status: 'paused' as never })); + + expect(agent.current.status).toBe('paused'); + expect(agent.current.messages).toHaveLength(2); + }); +}); diff --git a/packages/shared/src/features/interests/AgentContext.tsx b/packages/shared/src/features/interests/AgentContext.tsx new file mode 100644 index 00000000000..79cff9485f6 --- /dev/null +++ b/packages/shared/src/features/interests/AgentContext.tsx @@ -0,0 +1,802 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + InterestTurn, + UpdateInterestInput, + UserInterest, +} from '../../graphql/interests'; +import { + InterestRunStatus, + InterestRunTrigger, + UserInterestStatus, +} from '../../graphql/interests'; +import { useSendInterestCommand } from './hooks/useSendInterestCommand'; +import { useUpdateInterest } from './hooks/useUpdateInterest'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { interestHistoryQueryOptions } from './queries'; +import { generateQueryKey, RequestKey } from '../../lib/query'; +import type { Post } from '../../graphql/posts'; +import type { AgentAttachment, AgentBlock, AgentMessage } from './chat'; +import { promptWithContext } from './chat'; +import type { AgentFeedItem } from './hooks/useAgentFeed'; + +export type AgentSummaryPost = Pick< + Post, + 'id' | 'title' | 'createdAt' | 'contentHtml' +>; + +export type AgentContentTarget = + | { type: 'post'; post: Post } + | { type: 'feed'; label: string; posts: Post[] } + | { type: 'posts'; postId?: string } + | { type: 'activity' } + | { type: 'debug' }; + +export const contentTargetId = (target: AgentContentTarget): string => { + if (target.type === 'post') { + return `post:${target.post.id}`; + } + + if (target.type === 'feed') { + return `feed:${target.label}`; + } + + if (target.type === 'posts' && target.postId) { + return `posts:${target.postId}`; + } + + return target.type; +}; + +export type AgentActivityKind = + | 'run' + | 'command' + | 'finding' + | 'post' + | 'notification'; + +export type AgentActivityItem = { + id: string; + at: string; + kind: AgentActivityKind; + text: string; +}; + +type RunCommandArgs = { + text: string; + label?: string; + targetId?: string; + attachments?: AgentAttachment[]; + onComplete?: () => void; +}; + +type AgentContextValue = { + id: string; + interest?: UserInterest; + /** Held here rather than read off `interest`: the demo surface has no API. */ + status: UserInterestStatus; + isDemo: boolean; + isWorking: boolean; + workingLabel?: string; + /** Epoch ms the current run started. */ + workingSince?: number; + isTargetWorking: (targetId: string) => boolean; + runCommand: (args: RunCommandArgs) => void; + /** Standing feedback that never spends a run (reply votes). */ + sendFeedback: (text: string) => Promise; + queuedCommands: { id: string; text: string }[]; + removeQueuedCommand: (id: string) => void; + attachments: AgentAttachment[]; + attachContext: (attachment: AgentAttachment) => void; + detachContext: (id: string) => void; + composerRef: React.RefObject; + /** Consumed once: the composer clears it, so the same text can be rewritten. */ + draft?: string; + writeDraft: (text: string) => void; + clearDraft: () => void; + update: (data: UpdateInterestInput) => void; + isUpdating: boolean; + activity: AgentActivityItem[]; + messages: AgentMessage[]; + findingsPosts: Post[]; + summaryPosts: AgentSummaryPost[]; + isSettingsOpen: boolean; + setSettingsOpen: (open: boolean) => void; + openContent: AgentContentTarget[]; + activeContentId?: string; + activeContent?: AgentContentTarget; + openContentTarget: (target: AgentContentTarget) => void; + focusContent: (targetId: string) => void; + closeContent: (targetId: string) => void; + closeAllContent: () => void; +}; + +const AgentContext = createContext({} as AgentContextValue); + +export const useAgent = (): AgentContextValue => useContext(AgentContext); + +const demoWorkDurationMs = 2600; +const pendingPollMs = 5000; +const echoMatchWindowMs = 60000; + +// A timestamp alone is not unique: entries written in the same millisecond would +// share an id, which React reads as duplicate keys. +let idSequence = 0; +const nextId = (): string => { + idSequence += 1; + + return `${Date.now()}-${idSequence}`; +}; + +const isRunPending = (turn: InterestTurn): boolean => + turn.role === 'agent' && + (turn.status === InterestRunStatus.Queued || + turn.status === InterestRunStatus.Running); + +const mapServerBlocks = ( + turn: InterestTurn, + postsById: Map, + allPosts: Post[], +): AgentBlock[] => + (turn.blocks ?? []).reduce((acc, block) => { + if (block.type === 'text') { + acc.push(block); + return acc; + } + + if (block.type === 'picks') { + const posts = block.postIds.reduce((found, postId) => { + const post = postsById.get(postId); + if (post) { + found.push(post); + } + return found; + }, []); + + if (posts.length) { + acc.push({ type: 'picks', caption: block.caption, posts }); + } + return acc; + } + + if (allPosts.length) { + acc.push({ type: 'feedLink', label: block.label, posts: allPosts }); + } + return acc; + }, []); + +const turnsToMessages = ({ + turns, + postsById, + allPosts, + summaryPostsById, + interest, +}: { + turns: InterestTurn[]; + postsById: Map; + allPosts: Post[]; + summaryPostsById: Map; + interest?: UserInterest; +}): AgentMessage[] => { + const feedbackTextById = new Map( + turns + .filter((turn) => turn.role === 'user') + .map((turn) => [turn.id, turn.text ?? '']), + ); + + return turns.reduce((acc, turn) => { + if (turn.role === 'user') { + acc.push({ + id: turn.id, + role: 'user', + at: turn.createdAt, + text: turn.text ?? '', + }); + return acc; + } + + const isPending = isRunPending(turn); + const isError = turn.status === InterestRunStatus.Failed; + const retryText = turn.feedbackId + ? feedbackTextById.get(turn.feedbackId) + : (turn.trigger === InterestRunTrigger.Spawn && interest?.query) || + undefined; + const blocks = mapServerBlocks(turn, postsById, allPosts); + + if (!isPending && !isError && !blocks.length && !turn.summaryPostId) { + // A quiet run delivered nothing; a command still deserves an answer. + if (turn.trigger !== InterestRunTrigger.Command) { + return acc; + } + blocks.push({ + type: 'text', + html: '

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] = + useState(initialMessages); + const [echoes, setEchoes] = useState([]); + const [workingMeta, setWorkingMeta] = useState<{ + label: string; + targetId?: string; + }>(); + const [isSettingsOpen, setSettingsOpen] = useState(false); + const [statusOverride, setStatusOverride] = useState(); + const status = + statusOverride ?? interest?.status ?? UserInterestStatus.Active; + const [content, setContent] = useState<{ + items: AgentContentTarget[]; + activeId?: string; + }>({ items: [] }); + const [queuedCommands, setQueuedCommands] = useState< + { id: string; args: RunCommandArgs }[] + >([]); + const [attachments, setAttachments] = useState([]); + const [draft, setDraft] = useState(); + const composerRef = useRef(null); + const timeoutRef = useRef>(); + // `runCommand` decides queue-or-start during an event, before a re-render has + // delivered the new working state, so it reads the ref rather than the state. + const workingRef = useRef(false); + + useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + + // `useState` reads its argument once, and the demo transcript can be handed in + // after mount. Adopt it late, never over existing turns. + useEffect(() => { + if (!initialMessages.length) { + return; + } + + setDemoMessages((current) => (current.length ? current : initialMessages)); + }, [initialMessages]); + + // A sent echo also keeps the poll alive: its queued run may not be visible on + // the replica yet, and without polling it would stay pending forever. + const hasSentEcho = echoes.some((echo) => echo.state === 'sent'); + const historyQuery = useQuery({ + ...interestHistoryQueryOptions(id, user), + enabled: !isDemo && !!user?.id && !!id, + refetchInterval: (query) => + query.state.data?.some(isRunPending) || hasSentEcho + ? pendingPollMs + : false, + }); + const turns = useMemo( + () => (isDemo ? [] : historyQuery.data ?? []), + [historyQuery.data, isDemo], + ); + + const { postsById, allPosts } = useMemo(() => { + const byId = new Map(); + findings.forEach(({ post }) => byId.set(post.id, post)); + return { postsById: byId, allPosts: findings.map(({ post }) => post) }; + }, [findings]); + + const summaryPostsById = useMemo( + () => new Map(posts.map((post) => [post.id, post])), + [posts], + ); + + const unresolvedEchoes = useMemo( + () => echoes.filter((echo) => !echoResolved(echo, turns)), + [echoes, turns], + ); + + // Resolved echoes exist in the server history now; holding them longer would + // render the same turn twice. + useEffect(() => { + if (unresolvedEchoes.length !== echoes.length) { + setEchoes(unresolvedEchoes); + } + }, [echoes.length, unresolvedEchoes]); + + const messages = useMemo(() => { + if (isDemo) { + return demoMessages; + } + + const echoed = unresolvedEchoes.flatMap((echo) => [ + { + id: `${echo.id}-user`, + role: 'user', + at: new Date(echo.sentAt).toISOString(), + text: echo.text, + attachments: echo.attachments, + }, + { + id: `${echo.id}-agent`, + role: 'agent', + at: new Date(echo.sentAt).toISOString(), + isPending: echo.state !== 'error', + isError: echo.state === 'error', + retryText: echo.state === 'error' ? echo.text : undefined, + }, + ]); + + return [ + ...turnsToMessages({ + turns, + postsById, + allPosts, + summaryPostsById, + interest, + }), + ...echoed, + ]; + }, [ + allPosts, + demoMessages, + interest, + isDemo, + postsById, + summaryPostsById, + turns, + unresolvedEchoes, + ]); + + const activity = useMemo(() => { + if (isDemo) { + return []; + } + + const fromTurns = turns.reduce((acc, turn) => { + if (turn.role === 'user') { + acc.push({ + id: turn.id, + at: turn.createdAt, + kind: 'command', + text: turn.text ?? '', + }); + return acc; + } + + if (turn.status === InterestRunStatus.Completed) { + acc.push({ + id: turn.id, + at: turn.finishedAt ?? turn.createdAt, + kind: 'run', + text: turn.findingsAdded + ? `Run finished — added ${turn.findingsAdded} to your feed` + : 'Run finished — nothing cleared your quality bar', + }); + if (turn.summaryPostId) { + acc.push({ + id: `${turn.id}-post`, + at: turn.finishedAt ?? turn.createdAt, + kind: 'post', + text: 'Wrote a summary post', + }); + } + } else if (turn.status === InterestRunStatus.Failed) { + acc.push({ + id: turn.id, + at: turn.finishedAt ?? turn.createdAt, + kind: 'run', + text: 'A run failed before finishing', + }); + } + return acc; + }, []); + + const fromFindings = findings.map((finding) => ({ + id: finding.id, + at: finding.createdAt, + kind: 'finding', + text: finding.post.title + ? `Added "${finding.post.title}"` + : 'Added a finding', + })); + + return [...fromTurns, ...fromFindings].sort( + (a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(), + ); + }, [findings, isDemo, turns]); + + const serverPending = turns.some(isRunPending); + const echoPending = unresolvedEchoes.some((echo) => echo.state !== 'error'); + const isWorking = isDemo + ? !!workingMeta && workingRef.current + : serverPending || echoPending; + workingRef.current = isWorking; + + const pendingTurn = turns.find(isRunPending); + const pendingEcho = unresolvedEchoes.find((echo) => echo.state !== 'error'); + const workingSince = (() => { + if (pendingTurn) { + return new Date(pendingTurn.startedAt ?? pendingTurn.createdAt).getTime(); + } + return pendingEcho?.sentAt; + })(); + + // A finished run means fresh findings, posts, and lastRun fields. + const prevPendingRef = useRef(false); + useEffect(() => { + if (prevPendingRef.current && !serverPending) { + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.InterestFindings, user, id), + }); + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.Interests, user), + }); + } + prevPendingRef.current = serverPending; + }, [id, queryClient, serverPending, user]); + + const startRun = useCallback( + ({ + text, + label, + targetId, + attachments: pointedAt, + onComplete, + }: RunCommandArgs) => { + workingRef.current = true; + setWorkingMeta({ label: label ?? text, targetId }); + + if (isDemo || !interest) { + const stamp = nextId(); + setDemoMessages((current) => [ + ...current, + { + id: `${stamp}-user`, + role: 'user', + at: new Date().toISOString(), + text, + attachments: pointedAt, + }, + { + id: `${stamp}-agent`, + role: 'agent', + at: new Date().toISOString(), + isPending: true, + }, + ]); + displayToast('Sent to the agent. It will update in the background.'); + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + workingRef.current = false; + setWorkingMeta(undefined); + setDemoMessages((current) => + current.map((message) => + message.id === `${stamp}-agent` + ? { + ...message, + isPending: false, + at: new Date().toISOString(), + blocks: [ + { + type: 'text', + html: '

Sent. 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 = useMemo( + () => ({ + id, + interest, + status, + isDemo, + isWorking, + workingLabel: isWorking ? workingMeta?.label ?? 'Working' : undefined, + workingSince, + isTargetWorking: (targetId) => + isWorking && workingMeta?.targetId === targetId, + runCommand, + sendFeedback, + queuedCommands: queuedCommands.map(({ id: queuedId, args }) => ({ + id: queuedId, + text: args.text, + })), + removeQueuedCommand, + attachments, + attachContext, + detachContext, + composerRef, + draft, + writeDraft: setDraft, + clearDraft: () => setDraft(undefined), + update, + isUpdating, + activity, + messages, + findingsPosts: allPosts, + summaryPosts: posts, + isSettingsOpen, + setSettingsOpen, + openContent: content.items, + activeContentId: content.activeId, + activeContent: content.items.find( + (item) => contentTargetId(item) === content.activeId, + ), + openContentTarget, + focusContent, + closeContent, + closeAllContent, + }), + [ + attachContext, + attachments, + draft, + closeAllContent, + closeContent, + content, + detachContext, + focusContent, + openContentTarget, + activity, + messages, + allPosts, + posts, + id, + interest, + isDemo, + isSettingsOpen, + isUpdating, + isWorking, + queuedCommands, + removeQueuedCommand, + runCommand, + sendFeedback, + status, + update, + workingMeta, + workingSince, + ], + ); + + return ( + {children} + ); +}; diff --git a/packages/shared/src/features/interests/attachments.spec.ts b/packages/shared/src/features/interests/attachments.spec.ts new file mode 100644 index 00000000000..41e3c51c207 --- /dev/null +++ b/packages/shared/src/features/interests/attachments.spec.ts @@ -0,0 +1,197 @@ +import type { Post } from '../../graphql/posts'; +import { + activityAttachment, + agentAttachments, + feedAttachment, + mentionCandidates, + postAttachment, + quoteAttachment, + targetAttachment, +} from './attachments'; +import type { AgentMessage } from './chat'; +import { promptWithContext } from './chat'; + +const makePost = (id: string, title = `Post ${id}`): Post => + ({ + id, + title, + source: { name: 'GitHub' }, + } as Post); + +describe('postAttachment', () => { + it('carries the title and the source', () => { + expect(postAttachment(makePost('a', 'Zig 0.15'))).toEqual({ + id: 'post:a', + kind: 'post', + label: 'Zig 0.15', + detail: 'GitHub', + }); + }); + + it('labels an untitled post rather than rendering an empty chip', () => { + expect(postAttachment({ id: 'a' } as Post).label).toBe('Untitled post'); + }); + + it('keys on the post id, not the title', () => { + const first = postAttachment(makePost('a', 'One title')); + const second = postAttachment(makePost('a', 'A different title')); + + expect(first.id).toBe(second.id); + }); +}); + +describe('quoteAttachment', () => { + it('keeps a short passage whole', () => { + expect(quoteAttachment('comptime is not a macro system').label).toBe( + 'comptime is not a macro system', + ); + }); + + it('cuts a long passage to something a chip can hold', () => { + const { label } = quoteAttachment('x'.repeat(400)); + + expect(label).toHaveLength(141); + expect(label.endsWith('…')).toBe(true); + }); + + it('does not leave a dangling space before the ellipsis', () => { + const { label } = quoteAttachment(`${'x'.repeat(139)} word`); + + expect(label).not.toMatch(/ …$/); + }); +}); + +describe('feedAttachment and activityAttachment', () => { + it('counts the posts in a feed', () => { + expect(feedAttachment('Findings', [makePost('a'), makePost('b')])).toEqual({ + id: 'feed:Findings', + kind: 'feed', + label: 'Findings', + detail: '2 posts', + }); + }); + + it('points at one activity entry rather than the whole log', () => { + const item = { + id: 'act-1', + at: '2026-01-01T00:00:00.000Z', + kind: 'run' as const, + text: 'Scanned 128 posts', + }; + + expect(activityAttachment(item)).toEqual({ + id: 'activity:act-1', + kind: 'activity', + label: 'Scanned 128 posts', + detail: 'From the activity log', + }); + }); +}); + +describe('targetAttachment', () => { + it('maps a post tab to the post', () => { + expect(targetAttachment({ type: 'post', post: makePost('a') })?.id).toBe( + 'post:a', + ); + }); + + it('maps a feed tab to the feed', () => { + expect( + targetAttachment({ type: 'feed', label: 'Findings', posts: [] })?.id, + ).toBe('feed:Findings'); + }); + + it("maps the activity tab onto the agent's own run history", () => { + expect(targetAttachment({ type: 'activity' })?.id).toBe('agent:activity'); + }); + + it('has nothing to offer for the debug tab', () => { + expect(targetAttachment({ type: 'debug' })).toBeUndefined(); + }); +}); + +describe('mentionCandidates', () => { + const withPosts = (posts: Post[]): AgentMessage[] => [ + { id: 'm1', role: 'agent', at: '', blocks: [{ type: 'posts', posts }] }, + ]; + + it('offers what is open, then what it found, then the agent itself', () => { + const candidates = mentionCandidates({ + openContent: [{ type: 'post', post: makePost('open') }], + messages: withPosts([makePost('found')]), + }); + + expect(candidates.map(({ id }) => id)).toEqual([ + 'post:open', + 'post:found', + ...agentAttachments.map(({ id }) => id), + ]); + }); + + it('lists a post open in the panel once, not twice', () => { + const post = makePost('same'); + const candidates = mentionCandidates({ + openContent: [{ type: 'post', post }], + messages: withPosts([post]), + }); + + expect(candidates.filter(({ id }) => id === 'post:same')).toHaveLength(1); + }); + + it('reads the newest turn first, since that is the likeliest reference', () => { + const candidates = mentionCandidates({ + openContent: [], + messages: [ + { + id: 'm1', + role: 'agent', + at: '', + blocks: [{ type: 'posts', posts: [makePost('older')] }], + }, + { + id: 'm2', + role: 'agent', + at: '', + blocks: [{ type: 'picks', posts: [makePost('newer')] }], + }, + ], + }); + + expect(candidates.map(({ id }) => id).slice(0, 2)).toEqual([ + 'post:newer', + 'post:older', + ]); + }); + + it('survives turns that carry no blocks at all', () => { + expect(() => + mentionCandidates({ + openContent: [], + messages: [{ id: 'm1', role: 'user', at: '', text: 'hello' }], + }), + ).not.toThrow(); + }); + + it('always offers the agent itself, even with nothing on screen', () => { + expect( + mentionCandidates({ openContent: [], messages: [] }).map(({ id }) => id), + ).toEqual(agentAttachments.map(({ id }) => id)); + }); +}); + +describe('promptWithContext', () => { + it('sends the text untouched when nothing is attached', () => { + expect(promptWithContext('raise the bar', [])).toBe('raise the bar'); + }); + + it('names every attachment so the backend knows what was meant', () => { + const prompt = promptWithContext('why this one', [ + postAttachment(makePost('a', 'Zig 0.15')), + quoteAttachment('self-hosted backend'), + ]); + + expect(prompt).toContain('why this one'); + expect(prompt).toContain('Zig 0.15'); + expect(prompt).toContain('self-hosted backend'); + }); +}); diff --git a/packages/shared/src/features/interests/attachments.ts b/packages/shared/src/features/interests/attachments.ts new file mode 100644 index 00000000000..c2cd874b524 --- /dev/null +++ b/packages/shared/src/features/interests/attachments.ts @@ -0,0 +1,97 @@ +import type { Post } from '../../graphql/posts'; +import type { AgentActivityItem, AgentContentTarget } from './AgentContext'; +import type { AgentAttachment, AgentMessage } 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. +export const postAttachment = (post: Post): AgentAttachment => ({ + id: `post:${post.id}`, + kind: 'post', + label: post.title ?? 'Untitled post', + detail: post.source?.name, +}); + +export const feedAttachment = ( + label: string, + posts: Post[], +): AgentAttachment => ({ + id: `feed:${label}`, + kind: 'feed', + label, + detail: `${posts.length} posts`, +}); + +export const quoteAttachment = (text: string): AgentAttachment => ({ + id: `quote:${text}`, + kind: 'quote', + label: text.length > 140 ? `${text.slice(0, 140).trimEnd()}…` : text, + detail: 'Highlighted', +}); + +export const activityAttachment = ( + item: AgentActivityItem, +): AgentAttachment => ({ + id: `activity:${item.id}`, + kind: 'activity', + label: item.text, + detail: 'From the activity log', +}); + +export const agentAttachments: AgentAttachment[] = [ + { + id: 'agent:guidance', + kind: 'guidance', + label: 'Standing guidance', + detail: 'Everything you have told it so far', + }, + { + id: 'agent:activity', + kind: 'activity', + label: 'Run history', + detail: 'Every run, command and finding', + }, +]; + +export const targetAttachment = ( + target: AgentContentTarget, +): AgentAttachment | undefined => { + if (target.type === 'post') { + return postAttachment(target.post); + } + + if (target.type === 'feed') { + return feedAttachment(target.label, target.posts); + } + + return agentAttachments.find(({ id }) => id === `agent:${target.type}`); +}; + +const transcriptPosts = (messages: AgentMessage[]): Post[] => + messages + // Newest first, so deduping downstream keeps the most recent copy. + .slice() + .reverse() + .flatMap(({ blocks }) => blocks ?? []) + .flatMap((block) => (block.type === 'text' ? [] : block.posts)); + +export const mentionCandidates = ({ + openContent, + messages, +}: { + openContent: AgentContentTarget[]; + messages: AgentMessage[]; +}): AgentAttachment[] => { + const open = openContent.flatMap((target) => targetAttachment(target) ?? []); + const found = transcriptPosts(messages).map(postAttachment); + const seen = new Set(); + + return [...open, ...found, ...agentAttachments].filter(({ id }) => { + if (seen.has(id)) { + return false; + } + + seen.add(id); + + return true; + }); +}; diff --git a/packages/shared/src/features/interests/chat.ts b/packages/shared/src/features/interests/chat.ts new file mode 100644 index 00000000000..84dce562a60 --- /dev/null +++ b/packages/shared/src/features/interests/chat.ts @@ -0,0 +1,40 @@ +import type { Post } from '../../graphql/posts'; + +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[] }; + +export type AgentAttachment = { + id: string; + kind: 'post' | 'feed' | 'quote' | 'guidance' | 'activity'; + label: string; + detail?: string; +}; + +export type AgentMessage = { + id: string; + role: 'user' | 'agent'; + at: string; + text?: string; + attachments?: AgentAttachment[]; + blocks?: AgentBlock[]; + summaryPost?: Pick; + isPending?: boolean; + isScheduled?: boolean; + isError?: boolean; + retryText?: string; +}; + +// The API takes one string, so the attachments the chips carry have to be +// flattened into the prompt text. +export const promptWithContext = ( + text: string, + attachments: AgentAttachment[], +): string => + attachments.length + ? `${text}\n\nIn the context of: ${attachments + .map(({ label }) => `“${label}”`) + .join(', ')}` + : text; diff --git a/packages/shared/src/features/interests/commands.spec.ts b/packages/shared/src/features/interests/commands.spec.ts new file mode 100644 index 00000000000..8fc1624bd27 --- /dev/null +++ b/packages/shared/src/features/interests/commands.spec.ts @@ -0,0 +1,129 @@ +import { + agentCommands, + commandQuery, + findCommand, + matchCommands, + parseCommand, + quickCommandNames, +} from './commands'; + +describe('commandQuery', () => { + // The composer opens its list on empty-string vs undefined, so a truthiness + // check never opens it on a bare slash. + it('returns an empty string for a bare slash, not undefined', () => { + expect(commandQuery('/')).toBe(''); + }); + + it('returns the partial name while it is being typed', () => { + expect(commandQuery('/expl')).toBe('expl'); + expect(commandQuery('/raise-bar')).toBe('raise-bar'); + }); + + it('returns undefined once the field holds anything else', () => { + expect(commandQuery('/explore zig')).toBeUndefined(); + expect(commandQuery('hello')).toBeUndefined(); + expect(commandQuery('')).toBeUndefined(); + expect(commandQuery(' /explore')).toBeUndefined(); + }); +}); + +describe('parseCommand', () => { + it('resolves a bare command with no arguments', () => { + const parsed = parseCommand('/recap'); + + expect(parsed?.command.name).toBe('recap'); + expect(parsed?.args).toBe(''); + }); + + it('passes everything after the name through as arguments', () => { + const parsed = parseCommand('/explore comptime and allocators '); + + expect(parsed?.command.name).toBe('explore'); + expect(parsed?.args).toBe('comptime and allocators'); + }); + + it('handles a hyphenated name', () => { + expect(parseCommand('/raise-bar only benchmarks')?.command.name).toBe( + 'raise-bar', + ); + }); + + it('keeps newlines inside the arguments', () => { + expect(parseCommand('/write a post\nwith two paragraphs')?.args).toBe( + 'a post\nwith two paragraphs', + ); + }); + + it('is undefined for an unknown command or plain prose', () => { + expect(parseCommand('/nonsense')).toBeUndefined(); + expect(parseCommand('explore more please')).toBeUndefined(); + expect(parseCommand('/')).toBeUndefined(); + }); + + it('tolerates surrounding whitespace', () => { + expect(parseCommand(' /recap ')?.command.name).toBe('recap'); + }); +}); + +describe('matchCommands', () => { + it('matches on the label as well as the name', () => { + expect(matchCommands('post').map(({ name }) => name)).toContain('write'); + }); + + it('is case insensitive', () => { + expect(matchCommands('RECAP').map(({ name }) => name)).toEqual(['recap']); + }); + + it('returns everything for an empty query, so a bare slash lists them all', () => { + expect(matchCommands('')).toHaveLength(agentCommands.length); + }); + + it('returns nothing when nothing matches', () => { + expect(matchCommands('xyzzy')).toEqual([]); + }); +}); + +describe('the command table', () => { + it('has unique names, since findCommand resolves by name', () => { + const names = agentCommands.map(({ name }) => name); + + expect(new Set(names).size).toBe(names.length); + }); + + it('only uses names the parser can actually match', () => { + agentCommands.forEach(({ name }) => { + expect(parseCommand(`/${name}`)?.command.name).toBe(name); + }); + }); + + it('gives every command exactly one job: a prompt to send or a pane to open', () => { + agentCommands.forEach(({ name, prompt, opens }) => { + expect([name, !!prompt !== !!opens]).toEqual([name, true]); + }); + }); + + it('writes a non-empty prompt with and without arguments', () => { + agentCommands.forEach(({ name, prompt }) => { + if (!prompt) { + return; + } + + expect([name, prompt('').length > 0]).toEqual([name, true]); + expect([name, prompt('zig').length > 0]).toEqual([name, true]); + }); + }); + + it('offers only real commands as quick buttons', () => { + quickCommandNames.forEach((name) => { + expect(findCommand(name)?.name).toBe(name); + }); + }); + + it('gives every argument-taking command something to say about it', () => { + agentCommands + .filter(({ hint }) => hint) + .forEach(({ name, ask }) => { + expect([name, !!ask]).toEqual([name, true]); + }); + }); +}); diff --git a/packages/shared/src/features/interests/commands.ts b/packages/shared/src/features/interests/commands.ts new file mode 100644 index 00000000000..0852a38ccad --- /dev/null +++ b/packages/shared/src/features/interests/commands.ts @@ -0,0 +1,171 @@ +import type { ComponentType } from 'react'; +import type { IconProps } from '../../components/Icon'; +import { + AiIcon, + BulletListIcon, + FeatherIcon, + FilterIcon, + HotIcon, + MagicIcon, + SettingsIcon, + SourceIcon, + TerminalIcon, + TimerIcon, +} from '../../components/icons'; + +export type AgentCommand = { + name: string; + label: string; + description: string; + icon: ComponentType; + hint?: string; + ask?: string; + // `args` is whatever followed the name, already trimmed. + prompt?: (args: string) => string; + // A command with no `prompt` is local: it opens the workspace, no run spent. + opens?: 'settings' | 'activity' | 'debug'; +}; + +export const agentCommands: AgentCommand[] = [ + { + name: 'explore', + label: 'Explore more', + description: 'Widen the hunt past what it has already found.', + icon: MagicIcon, + hint: '[angle]', + ask: 'What angle? Or send it as it is…', + prompt: (args) => + args + ? `Explore more around ${args}` + : 'Explore more. Widen the hunt past what you have already found', + }, + { + name: 'write', + label: 'Write a post', + description: "Draft something publishable out of this run's findings.", + icon: FeatherIcon, + hint: '[format]', + ask: 'What format? Or send it as it is…', + prompt: (args) => + args + ? `Write me ${args} from what you found` + : 'Write me a post summarising what you found', + }, + { + name: 'raise-bar', + label: 'Raise the bar', + description: 'Tighten what counts as worth sending from now on.', + icon: AiIcon, + hint: '[what counts]', + ask: 'What should count from now on? Or send it as it is…', + prompt: (args) => + args + ? `Raise the bar. From now on only surface ${args}` + : 'Raise the bar. Only surface top-tier content from now on', + }, + { + name: 'sources', + label: 'Tune sources', + description: 'Add or drop the places it looks.', + icon: SourceIcon, + hint: '[add or drop]', + ask: 'What to add or drop? Or send it as it is…', + prompt: (args) => + args + ? `Change where you look: ${args}` + : 'Show me where you are looking and what you would add or drop', + }, + { + name: 'schedule', + label: 'Change cadence', + description: 'How often it runs on its own.', + icon: TimerIcon, + hint: '[how often]', + ask: 'How often? Or send it as it is…', + prompt: (args) => + args ? `Run ${args} from now on` : 'How often are you running right now?', + }, + { + name: 'why', + label: 'Explain a pick', + description: 'What made something clear the bar, in its own words.', + icon: FilterIcon, + hint: '[which pick]', + ask: 'Which pick? Or send it as it is…', + prompt: (args) => + args + ? `Explain why ${args} cleared the bar` + : 'Explain why your latest pick cleared the bar', + }, + { + name: 'recap', + label: 'Recap', + description: 'A written summary of everything since the last one.', + icon: BulletListIcon, + prompt: () => 'Recap everything you have found since the last summary', + }, + { + name: 'trending', + label: 'What is moving', + description: 'What the rest of daily.dev is reading on this topic.', + icon: HotIcon, + prompt: () => + 'What is moving on this topic across daily.dev right now, whether or not it cleared my bar?', + }, + { + name: 'settings', + label: 'Open settings', + description: 'Its name, cadence and standing guidance.', + icon: SettingsIcon, + opens: 'settings', + }, + { + name: 'activity', + label: 'Open activity', + description: 'Every run, command and finding in order.', + icon: TimerIcon, + opens: 'activity', + }, + { + name: 'debug', + label: 'Open debug', + description: 'The raw scoring behind the last run.', + icon: TerminalIcon, + opens: 'debug', + }, +]; + +export const quickCommandNames = ['explore', 'write', 'raise-bar']; + +export const findCommand = (name: string): AgentCommand | undefined => + agentCommands.find((command) => command.name === name); + +export const isCommandAvailable = ( + command: AgentCommand, + canDebug: boolean, +): boolean => command.opens !== 'debug' || canDebug; + +export const matchCommands = (query: string): AgentCommand[] => { + const term = query.toLowerCase(); + + return agentCommands.filter(({ name, label }) => + `${name} ${label.toLowerCase()}`.includes(term), + ); +}; + +// A bare `/` returns an empty string, so callers must check for `undefined` +// rather than for falsiness. +export const commandQuery = (value: string): string | undefined => { + const match = /^\/([a-z-]*)$/.exec(value); + + return match ? match[1] : undefined; +}; + +export const parseCommand = ( + value: string, +): { command: AgentCommand; args: string } | undefined => { + const match = /^\/([a-z-]+)(?:\s+([\s\S]*))?$/.exec(value.trim()); + const command = match ? findCommand(match[1]) : undefined; + + return command ? { command, args: (match?.[2] ?? '').trim() } : undefined; +}; diff --git a/packages/shared/src/features/interests/components/AgentActivitySection.spec.tsx b/packages/shared/src/features/interests/components/AgentActivitySection.spec.tsx new file mode 100644 index 00000000000..ae9aace3517 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentActivitySection.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { AgentProvider } from '../AgentContext'; +import { mockActivity } from '../mock'; +import { AgentActivitySection } from './AgentActivitySection'; + +const renderActivity = (isDemo: boolean) => + render( + + + + + , + ); + +const aMockEntry = () => screen.queryByText(mockActivity[0].text); + +/** + * Fabricated runs read exactly like real ones once they are in the list, so a + * reader has no way to tell which of their agent's history actually happened. + */ +describe('the activity tab', () => { + it('never attributes invented runs to a real agent', () => { + renderActivity(false); + + expect(aMockEntry()).not.toBeInTheDocument(); + }); + + it('says so plainly instead of filling the space', () => { + renderActivity(false); + + expect(screen.getByText(/Nothing yet/i)).toBeInTheDocument(); + }); + + it('still carries the scripted history on the demo surface', () => { + renderActivity(true); + + expect(aMockEntry()).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/interests/components/AgentActivitySection.tsx b/packages/shared/src/features/interests/components/AgentActivitySection.tsx new file mode 100644 index 00000000000..e3ac051ebbe --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentActivitySection.tsx @@ -0,0 +1,82 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + AiIcon, + BellIcon, + FeatherIcon, + MagicIcon, + RefreshIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { DateFormat } from '../../../components/utilities/DateFormat'; +import { TimeFormatType } from '../../../lib/dateFormat'; +import type { AgentActivityItem, AgentActivityKind } from '../AgentContext'; +import { useAgent } from '../AgentContext'; +import { mockActivity } from '../mock'; +import { activityAttachment } from '../attachments'; +import { AgentAddToChatButton } from './AgentAddToChatButton'; + +const kindIcon: Record = { + run: , + command: , + finding: , + post: , + notification: , +}; + +// Named group: an unnamed one lets the surrounding section reveal every row's +// button at once. +const ActivityRow = ({ item }: { item: AgentActivityItem }): ReactElement => ( + + + {kindIcon[item.kind]} + + + {item.text} + + + + + + +); + +export const AgentActivitySection = (): ReactElement => { + const { activity, isDemo } = useAgent(); + const items = isDemo ? [...activity, ...mockActivity] : activity; + + if (!items.length) { + return ( + + + Nothing yet. Runs and findings show up here. + + + ); + } + + return ( + + {items.map((item) => ( + + ))} + + ); +}; diff --git a/packages/shared/src/features/interests/components/AgentAddToChatButton.spec.tsx b/packages/shared/src/features/interests/components/AgentAddToChatButton.spec.tsx new file mode 100644 index 00000000000..be13d9cb02d --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentAddToChatButton.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { + mockMatchMedia, + noHoverQuery, +} from '../../../../__tests__/helpers/media'; +import { AgentProvider, useAgent } from '../AgentContext'; +import { AgentAddToChatButton } from './AgentAddToChatButton'; + +const setPointer = (canHover: boolean) => + mockMatchMedia((query) => query === noHoverQuery && !canHover); + +const attachment = { + id: 'post:p1', + kind: 'post' as const, + label: 'Zig 0.15 release notes', +}; + +const Chips = () => { + const { attachments } = useAgent(); + + return
{attachments.length}
; +}; + +const renderButton = (props: Partial<{ reveal: boolean }> = {}) => + render( + + + + + + , + ); + +const chipCount = () => screen.getByTestId('chips').textContent; + +beforeEach(() => { + jest.clearAllMocks(); + setPointer(true); +}); + +describe('AgentAddToChatButton', () => { + it('says what it does, since nobody arrives knowing this control', () => { + renderButton(); + + expect(screen.getByText('Add to chat')).toBeInTheDocument(); + }); + + it('points the next prompt at the thing it belongs to', () => { + renderButton(); + + fireEvent.click(screen.getByLabelText(/^Add to chat:/)); + + expect(chipCount()).toBe('1'); + }); + + it('says so once the thing is already in the chat', () => { + renderButton(); + + fireEvent.click(screen.getByLabelText(/^Add to chat:/)); + + expect(screen.getByText('In the chat')).toBeInTheDocument(); + expect(screen.getByLabelText(/^In the chat:/)).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + it('pressing it twice leaves one chip, not two', () => { + renderButton(); + + fireEvent.click(screen.getByLabelText(/^Add to chat:/)); + fireEvent.click(screen.getByLabelText(/^In the chat:/)); + + expect(chipCount()).toBe('1'); + }); + + it('says the same thing on a touch device as under a pointer', () => { + setPointer(false); + renderButton({ reveal: true }); + + expect(screen.getByText('Add to chat')).toBeInTheDocument(); + }); + + it('keeps the words where there is a pointer to reveal them', () => { + renderButton({ reveal: true }); + + expect(screen.getByText('Add to chat')).toBeInTheDocument(); + }); + + it('names what it is pointing at, for anyone listening rather than looking', () => { + renderButton(); + + expect( + screen.getByLabelText('Add to chat: Zig 0.15 release notes'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/interests/components/AgentAddToChatButton.tsx b/packages/shared/src/features/interests/components/AgentAddToChatButton.tsx new file mode 100644 index 00000000000..6799aa54510 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentAddToChatButton.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { Tooltip } from '../../../components/tooltip/Tooltip'; +import { AtIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import type { AgentAttachment } from '../chat'; +import { useAgent } from '../AgentContext'; + +export const addToChatFloat = 'absolute -top-3 right-3 z-1'; + +export const AgentAddToChatButton = ({ + attachment, + iconOnly, + reveal, + size = ButtonSize.XSmall, + className, + onMouseDown, + onAttached, +}: { + attachment: AgentAttachment; + iconOnly?: boolean; + reveal?: boolean; + size?: ButtonSize; + className?: string; + onMouseDown?: (event: React.MouseEvent) => void; + onAttached?: () => void; +}): ReactElement => { + const { attachContext, attachments } = useAgent(); + const isAttached = attachments.some(({ id }) => id === attachment.id); + const label = isAttached ? 'In the chat' : 'Add to chat'; + + return ( + + + + ); +}; diff --git a/packages/shared/src/features/interests/components/AgentAttachmentChip.tsx b/packages/shared/src/features/interests/components/AgentAttachmentChip.tsx new file mode 100644 index 00000000000..e9c18a9e9df --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentAttachmentChip.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { + AiIcon, + BulletListIcon, + DocsIcon, + FeedbackIcon, + MiniCloseIcon, + TimerIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import type { AgentAttachment } from '../chat'; + +export const attachmentIcon: Record = { + post: , + feed: , + quote: , + guidance: , + activity: , +}; + +export const AgentAttachmentChip = ({ + attachment, + onRemove, + className, +}: { + attachment: AgentAttachment; + onRemove?: () => void; + className?: string; +}): ReactElement => ( + // Matches the Subtle button's geometry: it sits among Subtle buttons. + + + {attachmentIcon[attachment.kind]} + + + {attachment.label} + + {onRemove && ( + + )} + +); diff --git a/packages/shared/src/features/interests/components/AgentChatSection.spec.tsx b/packages/shared/src/features/interests/components/AgentChatSection.spec.tsx new file mode 100644 index 00000000000..27b824a68b0 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentChatSection.spec.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { mockDesktop } from '../../../../__tests__/helpers/media'; +import basePost from '../../../../__tests__/fixture/post'; +import type { Post } from '../../../graphql/posts'; +import * as copy from '../../../hooks/useCopy'; +import * as lazyModal from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../../components/modals/common/types'; +import { Origin } from '../../../lib/log'; +import type { AgentMessage } from '../chat'; +import { AgentProvider } from '../AgentContext'; +import { AgentChatSection } from './AgentChatSection'; + +const post = (id: string, title: string): Post => + ({ + ...basePost, + id, + title, + commentsPermalink: `https://app.daily.dev/posts/${id}`, + } as Post); + +const reply: AgentMessage = { + id: 'm1', + role: 'agent', + at: new Date(0).toISOString(), + blocks: [ + { type: 'text', html: '

Daily 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( + + + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + mockDesktop(); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('copying a reply', () => { + const copied = () => { + const copyText = jest.fn(); + + jest.spyOn(copy, 'useCopyText').mockReturnValue([false, copyText]); + + return copyText; + }; + + it('carries the posts it cited, as links', () => { + const copyText = copied(); + renderTranscript(); + + fireEvent.click(screen.getByLabelText('Copy reply')); + + const [{ textToCopy }] = copyText.mock.calls[0]; + + expect(textToCopy).toContain('Daily run — kept 2.'); + expect(textToCopy).toContain( + '- [Zig 0.15](https://app.daily.dev/posts/p1)', + ); + expect(textToCopy).toContain( + '- [Ghostty is open source](https://app.daily.dev/posts/p2)', + ); + expect(textToCopy).toContain('Runners-up:'); + }); + + it('strips the markup rather than pasting HTML', () => { + const copyText = copied(); + renderTranscript(); + + fireEvent.click(screen.getByLabelText('Copy reply')); + + const [{ textToCopy }] = copyText.mock.calls[0]; + + expect(textToCopy).not.toContain('

'); + 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 ; + } + + if (block.type === 'feedLink') { + return ( + // The float lives on a wrapper: the card clips its own overflow and the + // action reaches past its top edge. +

+ } + title={block.label} + subtitle={`Feed · ${block.posts.length} posts`} + actionLabel="Open" + onAction={() => onFeedClick(block.label, block.posts)} + /> + +
+ ); + } + + if (block.type === 'picks') { + return ( + + {block.caption && ( + + {block.caption} + + )} + + + ); + } + + return ( + + {block.caption && ( + + {block.caption} + + )} + {block.posts.map((post) => ( + + ))} + + ); +}; + +const MessageActions = ({ + message, +}: { + message: AgentMessage; +}): ReactElement => { + const { attachContext, writeDraft, sendFeedback } = useAgent(); + const [, copyText] = useCopyText(); + const [isCopied, setCopied] = useState(false); + const [isSharing, setSharing] = useState(false); + const [vote, setVote] = useState<'up' | 'down'>(); + + const castVote = (next: 'up' | 'down') => { + if (vote) { + return; + } + + setVote(next); + const text = messageAsText(message); + sendFeedback( + `${next === 'up' ? 'More' : 'Fewer'} replies like this one: "${text.slice( + 0, + 140, + )}"`, + ).catch(() => setVote(undefined)); + }; + + useEffect(() => { + if (!isCopied) { + return undefined; + } + + const timer = setTimeout(() => setCopied(false), 2000); + + return () => clearTimeout(timer); + }, [isCopied]); + + const explain = () => { + const text = messageAsText(message); + + if (text) { + attachContext(quoteAttachment(text)); + } + + writeDraft( + vote === 'down' + ? 'I marked that one down because ' + : 'I marked that one up because ', + ); + }; + + return ( + + + + + + )} + + ); +}; + +const ErrorTurn = ({ message }: { message: AgentMessage }): ReactElement => { + const { runCommand } = useAgent(); + const { retryText } = message; + + return ( + + + + Something went wrong and this run didn't finish. + + {retryText && ( + + )} + + ); +}; + +const MessageRow = ({ + message, + onPostClick, + onFeedClick, + onSummaryClick, + activePostId, +}: { + message: AgentMessage; + onPostClick: (post: Post) => void; + onFeedClick: (label: string, posts: Post[]) => void; + onSummaryClick: (postId?: string) => void; + activePostId?: string; +}): ReactElement => { + if (message.role === 'user') { + return ( + + {!!message.attachments?.length && ( + + {message.attachments.map((attachment) => ( + + ))} + + )} +
+ + {message.text} + +
+
+ ); + } + + return ( + + {message.isScheduled && ( + + + + {'Scheduled run · '} + + + + )} + {message.isPending && } + {message.isError && } + {!message.isPending && !message.isError && ( + <> + {(message.blocks ?? []).map((block, index) => ( + + ))} + {!!message.summaryPost && ( + } + title={message.summaryPost.title ?? 'Summary post'} + subtitle="Post written this run" + actionLabel="Open" + onAction={() => onSummaryClick(message.summaryPost?.id)} + /> + )} + {!!message.blocks?.length && } + + )} + + ); +}; + +export const AgentChatSection = (): ReactElement => { + const { + messages, + openContentTarget, + activeContent, + queuedCommands, + removeQueuedCommand, + } = useAgent(); + const activePostId = + activeContent?.type === 'post' ? activeContent.post.id : undefined; + + return ( + + {messages.map((message) => ( + openContentTarget({ type: 'post', post })} + onFeedClick={(label, posts) => + openContentTarget({ type: 'feed', label, posts }) + } + onSummaryClick={(postId) => + openContentTarget({ type: 'posts', postId }) + } + activePostId={activePostId} + /> + ))} + {queuedCommands.map(({ id, text }) => ( + + + + + Queued + + + {text} + + +