diff --git a/packages/shared/src/features/interests/AgentContext.tsx b/packages/shared/src/features/interests/AgentContext.tsx new file mode 100644 index 00000000000..facd49148e1 --- /dev/null +++ b/packages/shared/src/features/interests/AgentContext.tsx @@ -0,0 +1,203 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + UpdateInterestInput, + UserInterest, +} from '../../graphql/interests'; +import { useSendInterestCommand } from './hooks/useSendInterestCommand'; +import { useUpdateInterest } from './hooks/useUpdateInterest'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import type { AgentMessage } from './chat'; +import { cannedReply } from './chat'; + +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; + onComplete?: () => void; +}; + +type AgentContextValue = { + id: string; + interest?: UserInterest; + isDemo: boolean; + isWorking: boolean; + workingLabel?: string; + isTargetWorking: (targetId: string) => boolean; + runCommand: (args: RunCommandArgs) => void; + update: (data: UpdateInterestInput) => void; + isUpdating: boolean; + activity: AgentActivityItem[]; + messages: AgentMessage[]; + isSettingsOpen: boolean; + setSettingsOpen: (open: boolean) => void; +}; + +const AgentContext = createContext({} as AgentContextValue); + +export const useAgent = (): AgentContextValue => useContext(AgentContext); + +const workDurationMs = 2600; + +export const AgentProvider = ({ + id, + interest, + isDemo, + initialMessages, + children, +}: { + id: string; + interest?: UserInterest; + isDemo: boolean; + initialMessages: AgentMessage[]; + children: ReactNode; +}): ReactElement => { + const { displayToast } = useToastNotification(); + const { sendCommand } = useSendInterestCommand(id); + const { isUpdating, updateInterest } = useUpdateInterest(id); + const [working, setWorking] = useState<{ + label: string; + targetId?: string; + } | null>(null); + const [activity, setActivity] = useState([]); + const [messages, setMessages] = useState(initialMessages); + const [isSettingsOpen, setSettingsOpen] = useState(false); + const timeoutRef = useRef>(); + + useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + + const runCommand = useCallback( + ({ text, label, targetId, onComplete }: RunCommandArgs) => { + if (!isDemo && interest) { + sendCommand(text).catch(() => undefined); + } else { + displayToast('Sent to the agent — it will update in the background'); + } + + const stamp = `${Date.now()}`; + setMessages((current) => [ + ...current, + { + id: `${stamp}-user`, + role: 'user', + at: new Date().toISOString(), + text, + }, + { + id: `${stamp}-agent`, + role: 'agent', + at: new Date().toISOString(), + isPending: true, + }, + ]); + + setWorking({ label: label ?? text, targetId }); + setActivity((current) => [ + { + id: `${Date.now()}`, + at: new Date().toISOString(), + kind: 'command', + text, + }, + ...current, + ]); + + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setWorking(null); + setMessages((current) => + current.map((message) => + message.id === `${stamp}-agent` + ? { + ...message, + isPending: false, + at: new Date().toISOString(), + blocks: cannedReply(text), + } + : message, + ), + ); + setActivity((current) => [ + { + id: `${Date.now()}-done`, + at: new Date().toISOString(), + kind: 'run', + text: `Applied "${text}" and refreshed the results`, + }, + ...current, + ]); + onComplete?.(); + }, workDurationMs); + }, + [displayToast, interest, isDemo, sendCommand], + ); + + const update = useCallback( + (data: UpdateInterestInput) => { + if (isDemo || !interest) { + return; + } + + updateInterest(data).catch(() => undefined); + }, + [interest, isDemo, updateInterest], + ); + + const value = useMemo( + () => ({ + id, + interest, + isDemo, + isWorking: !!working, + workingLabel: working?.label, + isTargetWorking: (targetId) => working?.targetId === targetId, + runCommand, + update, + isUpdating, + activity, + messages, + isSettingsOpen, + setSettingsOpen, + }), + [ + activity, + messages, + id, + interest, + isDemo, + isSettingsOpen, + isUpdating, + runCommand, + update, + working, + ], + ); + + return ( + {children} + ); +}; diff --git a/packages/shared/src/features/interests/chat.ts b/packages/shared/src/features/interests/chat.ts new file mode 100644 index 00000000000..a6619aec004 --- /dev/null +++ b/packages/shared/src/features/interests/chat.ts @@ -0,0 +1,109 @@ +import type { Post } from '../../graphql/posts'; +import { mockCollectionPost, mockFeedPosts } from './mockFeed'; + +export type AgentBlock = + | { type: 'text'; html: string } + | { type: 'posts'; caption?: string; posts: Post[] } + | { type: 'picks'; caption?: string; posts: Post[] } + | { type: 'collection'; post: Post } + | { type: 'feedLink'; label: string; count: number }; + +export type AgentMessage = { + id: string; + role: 'user' | 'agent'; + at: string; + text?: string; + blocks?: AgentBlock[]; + isPending?: boolean; + isScheduled?: boolean; +}; + +const minutesAgo = (minutes: number) => + new Date(Date.now() - 1000 * 60 * minutes).toISOString(); + +export const mockConversation: AgentMessage[] = [ + { + id: 'msg-1', + role: 'user', + at: minutesAgo(60 * 24 * 9), + text: 'Cool zig projects — keep me on top of what actually ships', + }, + { + id: 'msg-2', + role: 'agent', + at: minutesAgo(60 * 24 * 9 - 2), + blocks: [ + { + type: 'text', + html: `

Spawned. I'll hunt daily and only ping you when something clears your bar.

First pass over daily.dev turned up one thing I'd stop and read today:

`, + }, + { type: 'posts', posts: [mockFeedPosts[0]] }, + { + type: 'text', + html: `

Eight more cleared the bar but are lower confidence.

`, + }, + { type: 'feedLink', label: 'Open all 9 findings as a feed', count: 9 }, + ], + }, + { + id: 'msg-3', + role: 'user', + at: minutesAgo(60 * 14), + text: 'fewer announcements, more source-level deep dives', + }, + { + id: 'msg-4', + role: 'agent', + at: minutesAgo(60 * 14 - 1), + blocks: [ + { + type: 'text', + html: `

Noted. I dropped announcement-only posts and reweighted toward material written by the people who did the work. Volume went from ~14 a day to 6.

Re-scored what I already had — tap any of these to see why I kept it:

`, + }, + { + type: 'picks', + posts: [mockFeedPosts[4], mockFeedPosts[7], mockFeedPosts[1]], + }, + ], + }, + { + id: 'msg-5', + role: 'agent', + at: minutesAgo(42), + isScheduled: true, + blocks: [ + { + type: 'text', + html: `

Daily run — scanned 128 posts, kept 6.

Five sources covered the same release, so I merged them into one card instead of sending you five:

`, + }, + { type: 'collection', post: mockCollectionPost }, + { + type: 'text', + html: `

Here's the write-up you asked me to keep doing:

+

Zig this week

+

The one thing to read: Zig 0.15 makes the self-hosted backend the default. Debug builds no longer need LLVM — that's why everyone is posting compile-time screenshots. Incremental compilation is in, but behind a flag, so don't plan around it yet.

+

Worth cloning: Bun's new bundler (Go dependency gone, 3.1x cold start), Ghostty (now open source, GPU rendering on macOS and Linux), and TigerBeetle's post-mortem — the most honest thing I read this week.

+

What I skipped: four "Zig vs Rust" posts re-running the same microbenchmark, and two release announcements with no notes attached.

`, + }, + { + type: 'picks', + caption: 'The three projects, if you want them directly:', + posts: [mockFeedPosts[0], mockFeedPosts[5], mockFeedPosts[2]], + }, + ], + }, +]; + +export const cannedReply = (command: string): AgentBlock[] => [ + { + type: 'text', + html: `

Applied “${command}” and re-ran the hunt. It's saved as standing guidance, so every future run and live match uses it too.

Closest matches under the new weighting:

`, + }, + { type: 'posts', posts: [mockFeedPosts[2]] }, + { + type: 'picks', + caption: 'Runners-up under the new weighting:', + posts: [mockFeedPosts[3], mockFeedPosts[8]], + }, + { type: 'feedLink', label: 'Open the refreshed feed', count: 6 }, +]; 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..bea44d3e96a --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentActivitySection.tsx @@ -0,0 +1,59 @@ +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'; + +const kindIcon: Record = { + run: , + command: , + finding: , + post: , + notification: , +}; + +const ActivityRow = ({ item }: { item: AgentActivityItem }): ReactElement => ( + + + {kindIcon[item.kind]} + + + {item.text} + + + + + +); + +export const AgentActivitySection = (): ReactElement => { + const { activity } = useAgent(); + const items = [...activity, ...mockActivity]; + + return ( + + {items.map((item) => ( + + ))} + + ); +}; 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..977a54261fa --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentChatSection.tsx @@ -0,0 +1,239 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import dynamic from 'next/dynamic'; +import { ArticleList } from '../../../components/cards/article/ArticleList'; +import { CollectionList } from '../../../components/cards/collection/CollectionList'; +import Markdown from '../../../components/Markdown'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { AiIcon, ArrowIcon, TimerIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { DateFormat } from '../../../components/utilities/DateFormat'; +import { TimeFormatType } from '../../../lib/dateFormat'; +import type { Post } from '../../../graphql/posts'; +import { PostType } from '../../../graphql/posts'; +import type { AgentBlock, AgentMessage } from '../chat'; +import { useAgent } from '../AgentContext'; +import { AgentPickList } from './AgentPickList'; + +const ArticlePostModal = dynamic( + () => + import( + /* webpackChunkName: "articlePostModal" */ '../../../components/modals/ArticlePostModal' + ), +); +const CollectionPostModal = dynamic( + () => + import( + /* webpackChunkName: "collectionPostModal" */ '../../../components/modals/CollectionPostModal' + ), +); + +const noop = () => undefined; + +const PendingBubble = (): ReactElement => ( + + {[0, 150, 300].map((delay) => ( + + ))} + +); + +const BlockRenderer = ({ + block, + onPostClick, +}: { + block: AgentBlock; + onPostClick: (post: Post) => void; +}): ReactElement => { + if (block.type === 'text') { + return ; + } + + if (block.type === 'feedLink') { + return ( + + ); + } + + if (block.type === 'picks') { + return ( + + {block.caption && ( + + {block.caption} + + )} + + + ); + } + + if (block.type === 'collection') { + return ( + { + event?.preventDefault(); + onPostClick(post); + }} + onUpvoteClick={noop} + onDownvoteClick={noop} + onCommentClick={noop} + onBookmarkClick={noop} + onCopyLinkClick={noop} + onShare={noop} + /> + ); + } + + return ( + + {block.caption && ( + + {block.caption} + + )} + {block.posts.map((post) => ( + { + event?.preventDefault(); + onPostClick(clicked); + }} + onUpvoteClick={noop} + onDownvoteClick={noop} + onCommentClick={noop} + onBookmarkClick={noop} + onCopyLinkClick={noop} + onShare={noop} + /> + ))} + + ); +}; + +const MessageRow = ({ + message, + onPostClick, +}: { + message: AgentMessage; + onPostClick: (post: Post) => void; +}): ReactElement => { + if (message.role === 'user') { + return ( + +
+ {message.text} +
+ + + +
+ ); + } + + return ( + + + + + + + + Your agent + + {message.isScheduled && ( + + + + Scheduled run + + + )} + + + + + {message.isPending ? ( + + ) : ( + (message.blocks ?? []).map((block, index) => ( + + )) + )} + + + ); +}; + +export const AgentChatSection = (): ReactElement => { + const { messages } = useAgent(); + const [activePost, setActivePost] = useState(); + const isCollection = activePost?.type === PostType.Collection; + const PostModal = isCollection ? CollectionPostModal : ArticlePostModal; + + return ( + <> + + {messages.map((message) => ( + + ))} + + {activePost && ( + setActivePost(undefined)} + /> + )} + + ); +}; diff --git a/packages/shared/src/features/interests/components/AgentDebugPanel.tsx b/packages/shared/src/features/interests/components/AgentDebugPanel.tsx new file mode 100644 index 00000000000..0427f10e948 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentDebugPanel.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import Link from '../../../components/utilities/Link'; +import type { AgentFeedItem } from '../hooks/useAgentFeed'; +import { useAgent } from '../AgentContext'; + +export const AgentDebugPanel = ({ + items, + onDelete, + isDeleting, +}: { + items: AgentFeedItem[]; + onDelete: () => void; + isDeleting: boolean; +}): ReactElement => { + const { interest, isDemo } = useAgent(); + + return ( + + + + Raw state + + {isDemo && ( + + Showing demo content + + )} + + +
+        {JSON.stringify(interest, null, 2)}
+      
+ + {`Findings (${items.length})`} + + + {items.map((item) => ( + + + {item.post.title} + + + {`score ${item.score.toFixed(2)} · ${item.rationale}`} + + + ))} + +
+ ); +}; diff --git a/packages/shared/src/features/interests/components/AgentHero.tsx b/packages/shared/src/features/interests/components/AgentHero.tsx new file mode 100644 index 00000000000..4535474f6b1 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentHero.tsx @@ -0,0 +1,145 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { AiIcon, SettingsIcon, TimerIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { DateFormat } from '../../../components/utilities/DateFormat'; +import { TimeFormatType } from '../../../lib/dateFormat'; +import { UserInterestStatus } from '../../../graphql/interests'; +import { useAgent } from '../AgentContext'; + +const statusCopy: Record = { + [UserInterestStatus.Active]: { + label: 'Hunting', + dot: 'bg-action-upvote-default', + }, + [UserInterestStatus.Paused]: { label: 'Paused', dot: 'bg-text-quaternary' }, + [UserInterestStatus.Stopped]: { label: 'Stopped', dot: 'bg-text-quaternary' }, +}; + +const cadenceCopy: Record = { + hourly: 'every hour', + daily: 'every day', + weekly: 'every week', +}; + +const Stat = ({ + label, + value, +}: { + label: string; + value: string; +}): ReactElement => ( + + + {value} + + + {label} + + +); + +export const AgentHero = ({ + findingsCount, + postsCount, +}: { + findingsCount: number; + postsCount: number; +}): ReactElement => { + const { interest, isWorking, workingLabel, setSettingsOpen } = useAgent(); + const status = interest?.status ?? UserInterestStatus.Active; + const { label, dot } = statusCopy[status]; + + return ( + + + + + + + + {interest?.query ?? 'Your interest agent'} + + + + + + {isWorking ? 'Working' : label} + + + + {isWorking + ? workingLabel + : `Runs ${cadenceCopy[interest?.cadence ?? 'daily']}`} + + {!isWorking && interest?.lastRunAt && ( + + {'· last run '} + + + )} + + + + {isExpanded && ( +
+ {post.summary && ( + + {post.summary} + + )} + +
+ )} + + ); +}; + +export const AgentPickList = ({ + posts, + onOpen, +}: { + posts: Post[]; + onOpen: (post: Post) => void; +}): ReactElement => ( +
    + {posts.map((post) => ( + + ))} +
+); diff --git a/packages/shared/src/features/interests/components/AgentSettingsModal.tsx b/packages/shared/src/features/interests/components/AgentSettingsModal.tsx new file mode 100644 index 00000000000..449c8b6c618 --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentSettingsModal.tsx @@ -0,0 +1,251 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import type { ModalProps } from '../../../components/modals/common/Modal'; +import { Modal } from '../../../components/modals/common/Modal'; +import { ModalKind, ModalSize } from '../../../components/modals/common/types'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { Switch } from '../../../components/fields/Switch'; +import { Slider } from '../../../components/fields/Slider'; +import { Radio } from '../../../components/fields/Radio'; +import { HourDropdown } from '../../../components/fields/HourDropdown'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + UserInterestCadence, + UserInterestStatus, +} from '../../../graphql/interests'; +import { useAgent } from '../AgentContext'; + +const cadenceOptions = [ + { value: UserInterestCadence.Hourly, label: 'Every hour' }, + { value: UserInterestCadence.Daily, label: 'Every day' }, + { value: UserInterestCadence.Weekly, label: 'Every week' }, +]; + +const outputOptions = [ + { + key: 'feed', + label: 'Add findings to my feed', + hint: 'Curated cards you can browse here', + }, + { + key: 'post', + label: 'Write summary posts', + hint: 'A markdown recap of what it found', + }, + { + key: 'notification', + label: 'Notify me about new content', + hint: 'In-app and push ping when something lands', + }, + { + key: 'digest', + label: 'Send a digest email', + hint: 'Up to 5 picks, delivered at your chosen time', + }, +] as const; + +const sourceOptions = [ + { key: 'dailyDev', label: 'daily.dev', disabled: false }, + { key: 'web', label: 'The web', disabled: true }, + { key: 'github', label: 'GitHub', disabled: true }, +] as const; + +const Section = ({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: React.ReactNode; +}): ReactElement => ( + + + + {title} + + {hint && ( + + {hint} + + )} + + {children} + +); + +export const AgentSettingsModal = ({ + onRequestClose, + ...props +}: ModalProps): ReactElement => { + const { interest, update, isUpdating } = useAgent(); + const [fomo, setFomo] = useState(null); + const [hour, setHour] = useState(8); + const isStopped = interest?.status === UserInterestStatus.Stopped; + const threshold = fomo ?? interest?.fomoThreshold ?? 0.5; + + return ( + + + +
+ update({ cadence })} + /> + + + Deliver around + + + +
+ +
0.7 + ? 'Only the very best makes it through.' + : 'You will see more, including the borderline stuff.' + } + > + setFomo(value)} + onValueCommit={([value]) => update({ fomoThreshold: value })} + /> + + + Show me everything + + + Only the best + + +
+ +
+ {outputOptions.map(({ key, label, hint }) => ( + + + update({ + outputModes: { [key]: !interest?.outputModes?.[key] }, + }) + } + > + {label} + + + {hint} + + + ))} +
+ +
+ {sourceOptions.map(({ key, label, disabled }) => ( + + update({ sources: { [key]: !interest?.sources?.[key] } }) + } + > + {label} + + ))} +
+ +
+ + + + +
+
+ + + +
+ ); +}; diff --git a/packages/shared/src/features/interests/components/AgentToolbar.tsx b/packages/shared/src/features/interests/components/AgentToolbar.tsx new file mode 100644 index 00000000000..7a04dff471b --- /dev/null +++ b/packages/shared/src/features/interests/components/AgentToolbar.tsx @@ -0,0 +1,160 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { TextField } from '../../../components/fields/TextField'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + AiIcon, + FeatherIcon, + MagicIcon, + PauseIcon, + PlayIcon, + SendAirplaneIcon, + SettingsIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { UserInterestStatus } from '../../../graphql/interests'; +import { useAgent } from '../AgentContext'; + +const quickActions = [ + { label: 'Explore more', icon: , command: 'Explore more' }, + { + label: 'Write me a post', + icon: , + command: 'Write me a post summarising what you found', + }, + { + label: 'Only the best', + icon: , + command: 'Raise the bar — only surface top-tier content from now on', + }, +]; + +export const AgentToolbar = (): ReactElement => { + const { + interest, + isWorking, + workingLabel, + runCommand, + update, + setSettingsOpen, + } = useAgent(); + const [feedback, setFeedback] = useState(''); + const isPaused = interest?.status !== UserInterestStatus.Active; + + const onSubmit = () => { + const trimmed = feedback.trim(); + if (!trimmed) { + return; + } + + runCommand({ text: trimmed, label: `Applying “${trimmed}”` }); + setFeedback(''); + }; + + return ( +
+ + + + + + + {isWorking + ? `${workingLabel} — updating in the background…` + : 'Your agent is listening. Tell it what to change.'} + + + ))} + + +
+ ); +}; diff --git a/packages/shared/src/features/interests/hooks/useAgentFeed.ts b/packages/shared/src/features/interests/hooks/useAgentFeed.ts new file mode 100644 index 00000000000..6b448548f0a --- /dev/null +++ b/packages/shared/src/features/interests/hooks/useAgentFeed.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import type { Post } from '../../../graphql/posts'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { interestFindingsQueryOptions } from '../queries'; +import { mockFeedItems } from '../mockFeed'; + +export type AgentFeedItem = { + id: string; + post: Post; + score: number; + rationale: string; + createdAt: string; +}; + +export const useAgentFeed = ({ + id, + forceDemo, +}: { + id: string; + forceDemo: boolean; +}) => { + const { user } = useAuthContext(); + const findingsQuery = useQuery({ + ...interestFindingsQueryOptions(id, user), + enabled: !!user?.id && !!id && !forceDemo, + }); + const findings = findingsQuery.data ?? []; + const isDemo = + forceDemo || (!findingsQuery.isPending && findings.length === 0); + + const realItems = findings.reduce((acc, finding) => { + if (!finding.post) { + return acc; + } + + acc.push({ + id: finding.id, + post: finding.post, + score: finding.score, + rationale: finding.rationale ?? '', + createdAt: finding.createdAt, + }); + + return acc; + }, []); + const items: AgentFeedItem[] = isDemo ? mockFeedItems : realItems; + + return { + items, + isDemo, + isPending: isDemo ? false : findingsQuery.isPending, + isFetching: isDemo ? false : findingsQuery.isFetching, + refetch: findingsQuery.refetch, + }; +}; diff --git a/packages/shared/src/features/interests/mock.ts b/packages/shared/src/features/interests/mock.ts new file mode 100644 index 00000000000..d3fe0e148fd --- /dev/null +++ b/packages/shared/src/features/interests/mock.ts @@ -0,0 +1,88 @@ +import type { AgentActivityItem } from './AgentContext'; +import type { UserInterest } from '../../graphql/interests'; +import { + UserInterestCadence, + UserInterestStatus, +} from '../../graphql/interests'; + +export const mockInterest: UserInterest = { + id: 'demo', + query: 'Cool zig projects', + status: UserInterestStatus.Active, + cadence: UserInterestCadence.Daily, + fomoThreshold: 0.62, + sources: { dailyDev: true, web: false, github: false }, + outputModes: { feed: true, post: true, digest: false, notification: true }, + lastRunAt: new Date(Date.now() - 1000 * 60 * 42).toISOString(), + lastRunSummary: 'Scanned 128 posts, kept 6', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 9).toISOString(), + updatedAt: new Date().toISOString(), +}; + +export const mockAgentPosts = [ + { + id: 'mock-post-1', + title: + 'Zig this week: the self-hosted backend, and three projects worth cloning', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), + contentHtml: `

I went through 128 posts and kept nine. Here is what actually matters.

+

The one thing to read

+

Zig 0.15 makes the self-hosted backend the default. Debug builds no longer need LLVM, which is why everyone is posting compile-time screenshots. Incremental compilation is in, but behind a flag — do not plan around it yet.

+

Projects worth cloning

+
    +
  1. Bun's new bundler — the Go dependency is gone and module resolution moved into Zig. The 3.1x cold-start number holds up on their benchmark suite; watch mode is unchanged.
  2. +
  3. Ghostty — now open source, GPU rendering on both macOS and Linux. The config format deliberately has no scripting, which is the interesting design call.
  4. +
  5. TigerBeetle — their post-mortem is the most honest thing I read this week. Deterministic simulation found the bug; nobody read the report.
  6. +
+

What I skipped

+

Four "Zig vs Rust" posts that re-ran the same microbenchmark, and two release announcements with no notes attached. You told me to cut announcement noise, so they did not make it in.

`, + }, + { + id: 'mock-post-2', + title: 'What changed in your interest since Tuesday', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(), + contentHtml: `

Two threads are converging.

+

Tooling is consolidating. zig cc keeps showing up as a full cross-compilation replacement rather than an experiment — the InfoQ write-up is a year of production use, not a first impression.

+

The performance debate moved on. Microbenchmarks are out; people are comparing build times and debug ergonomics instead, which is a much more useful signal for picking a language.

+

I lowered the weight on opinion pieces after your last note and picked up more source-level material. That dropped my volume from ~14 findings a day to 6, which should be closer to what you wanted.

`, + }, +]; + +export const mockActivity: AgentActivityItem[] = [ + { + id: 'mock-activity-1', + at: new Date(Date.now() - 1000 * 60 * 42).toISOString(), + kind: 'finding', + text: 'Scanned 128 new posts, added 6 to your feed', + }, + { + id: 'mock-activity-2', + at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), + kind: 'post', + text: 'Wrote "Zig this week: the self-hosted backend, and three projects worth cloning"', + }, + { + id: 'mock-activity-3', + at: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString(), + kind: 'notification', + text: 'Notified you about 6 new findings', + }, + { + id: 'mock-activity-4', + at: new Date(Date.now() - 1000 * 60 * 60 * 14).toISOString(), + kind: 'command', + text: 'You asked for fewer announcements, more source-level deep dives', + }, + { + id: 'mock-activity-5', + at: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), + kind: 'run', + text: 'Scheduled run — 41 posts scanned, nothing passed your quality bar', + }, + { + id: 'mock-activity-6', + at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(), + kind: 'finding', + text: 'Picked up Ghostty going open source 4 minutes after it went live', + }, +]; diff --git a/packages/shared/src/features/interests/mockFeed.ts b/packages/shared/src/features/interests/mockFeed.ts new file mode 100644 index 00000000000..5d7684f352a --- /dev/null +++ b/packages/shared/src/features/interests/mockFeed.ts @@ -0,0 +1,317 @@ +import type { Post } from '../../graphql/posts'; +import { PostType, UserVote } from '../../graphql/posts'; +import { SourceType } from '../../graphql/sources'; + +const logo = (handle: string) => + `https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/${handle}`; +const cover = (index: number) => + `https://media.daily.dev/image/upload/f_auto/v1/placeholders/${index}`; + +const sources = { + github: { + id: 'github', + handle: 'github', + name: 'GitHub', + permalink: 'https://app.daily.dev/sources/github', + image: logo('github'), + type: SourceType.Machine, + public: true, + }, + hn: { + id: 'hn', + handle: 'hn', + name: 'Hacker News', + permalink: 'https://app.daily.dev/sources/hn', + image: logo('hn'), + type: SourceType.Machine, + public: true, + }, + medium: { + id: 'medium', + handle: 'medium', + name: 'Medium', + permalink: 'https://app.daily.dev/sources/medium', + image: logo('medium'), + type: SourceType.Machine, + public: true, + }, + infoq: { + id: 'infoq', + handle: 'infoq', + name: 'InfoQ', + permalink: 'https://app.daily.dev/sources/infoq', + image: logo('infoq'), + type: SourceType.Machine, + public: true, + }, + echojs: { + id: 'echojs', + handle: 'echojs', + name: 'Echo JS', + permalink: 'https://app.daily.dev/sources/echojs', + image: logo('echojs'), + type: SourceType.Machine, + public: true, + }, + ph: { + id: 'ph', + handle: 'ph', + name: 'Product Hunt', + permalink: 'https://app.daily.dev/sources/ph', + image: logo('ph'), + type: SourceType.Machine, + public: true, + }, +} as unknown as Record; + +const hoursAgo = (hours: number) => + new Date(Date.now() - 1000 * 60 * 60 * hours).toISOString(); + +type MockPostSeed = { + id: string; + title: string; + summary: string; + source: Post['source']; + tags: string[]; + readTime: number; + numUpvotes: number; + numComments: number; + hours: number; + domain: string; + cover: number; + score: number; + rationale: string; + type?: PostType; + contentHtml?: string; +}; + +const seeds: MockPostSeed[] = [ + { + id: 'mock-finding-1', + title: + 'Bun 2.0 ships a Zig-powered bundler that beats esbuild on cold start', + summary: + 'The rewrite moves module resolution into Zig and drops the Go dependency entirely. Cold-start numbers are 3.1x better on the benchmark suite, with the caveat that watch mode is unchanged.', + source: sources.github, + tags: ['zig', 'bun', 'performance'], + readTime: 7, + numUpvotes: 412, + numComments: 63, + hours: 3, + domain: 'github.com', + cover: 1, + score: 0.94, + rationale: + 'Real benchmark numbers on a Zig rewrite, not an announcement — this is the kind of source-level post you keep opening.', + }, + { + id: 'mock-finding-2', + title: + 'Writing a toy database in Zig: allocators, comptime and no hidden control flow', + summary: + 'A maintainer walks through building a B-tree store from scratch, focusing on how explicit allocators change the design compared to the same project in Rust.', + source: sources.medium, + tags: ['zig', 'databases', 'systems'], + readTime: 18, + numUpvotes: 287, + numComments: 41, + hours: 9, + domain: 'medium.com', + cover: 2, + score: 0.89, + rationale: + 'Build-it-from-scratch walkthrough by a maintainer. Matches your interest in how allocators shape a design.', + }, + { + id: 'mock-finding-3', + title: + 'TigerBeetle post-mortem: the deterministic simulator caught a bug we shipped anyway', + summary: + 'An honest write-up of a production incident, and why deterministic simulation testing only helps if you actually read the failures it reports.', + source: sources.hn, + tags: ['zig', 'testing', 'distributed-systems'], + readTime: 12, + numUpvotes: 934, + numComments: 188, + hours: 14, + domain: 'tigerbeetle.com', + cover: 3, + score: 0.87, + rationale: + 'Honest post-mortem from the TigerBeetle team. You upvoted their last two write-ups.', + }, + { + id: 'mock-finding-4', + title: 'Zig 0.15 release notes: the self-hosted backend is now the default', + summary: + 'LLVM is no longer required for debug builds. Compile times drop sharply, and the incremental compilation work finally lands behind a flag.', + source: sources.github, + tags: ['zig', 'compilers', 'release'], + readTime: 9, + numUpvotes: 1204, + numComments: 233, + hours: 22, + domain: 'ziglang.org', + cover: 4, + score: 0.85, + rationale: + 'Core release news. Kept it even though you asked for fewer announcements — the self-hosted backend is a real shift.', + }, + { + id: 'mock-finding-5', + title: 'Cross-compiling C projects with zig cc, one year in', + summary: + 'A practical account of replacing an entire cross-compilation toolchain with zig cc — what worked, what broke, and the two cases that still need the old setup.', + source: sources.infoq, + tags: ['zig', 'c', 'toolchain'], + readTime: 11, + numUpvotes: 198, + numComments: 27, + hours: 30, + domain: 'infoq.com', + cover: 5, + score: 0.78, + rationale: + 'A year of production use rather than a first impression, which is what you said you wanted more of.', + }, + { + id: 'mock-finding-6', + title: + 'Ghostty is now open source: a terminal written in Zig with native rendering everywhere', + summary: + 'The long-awaited release includes GPU-accelerated rendering on macOS and Linux, plus a config format that deliberately avoids scripting.', + source: sources.ph, + tags: ['zig', 'terminal', 'open-source'], + readTime: 6, + numUpvotes: 1583, + numComments: 302, + hours: 38, + domain: 'ghostty.org', + cover: 6, + score: 0.74, + rationale: + 'Big launch and very high engagement. Slightly outside "projects" but the source is worth a read.', + }, + { + id: 'mock-finding-7', + title: 'Benchmarking Zig against Rust and C for a hot-path parser', + summary: + 'All three land within noise of each other once you match allocation strategies — the interesting differences are in build times and debug ergonomics.', + source: sources.echojs, + tags: ['zig', 'rust', 'benchmarks'], + readTime: 14, + numUpvotes: 156, + numComments: 89, + hours: 46, + domain: 'echojs.com', + cover: 7, + score: 0.66, + rationale: + 'Benchmarks are noisy, but the build-time comparison is the useful part.', + }, + { + id: 'mock-finding-8', + title: 'comptime is not a macro system, and treating it like one will hurt', + summary: + 'A short argument about the mental model behind Zig comptime, with three refactors that get simpler once you stop reaching for code generation.', + source: sources.medium, + tags: ['zig', 'language-design'], + readTime: 8, + numUpvotes: 341, + numComments: 54, + hours: 55, + domain: 'medium.com', + cover: 8, + score: 0.61, + rationale: + 'Opinion piece — lower confidence. Included because the refactors at the end are concrete.', + }, + { + id: 'mock-finding-9', + title: 'Six Zig projects worth reading the source of', + summary: + 'Short annotated tour of codebases that show idiomatic Zig, ordered by how approachable they are on a first read.', + source: sources.hn, + tags: ['zig', 'open-source', 'learning'], + readTime: 10, + numUpvotes: 622, + numComments: 71, + hours: 68, + domain: 'news.ycombinator.com', + cover: 1, + score: 0.55, + rationale: + 'Link roundup. Borderline for your quality bar, but it points at six codebases you have not seen yet.', + }, +]; + +const toMockPost = (seed: MockPostSeed): Post => + ({ + id: seed.id, + title: seed.title, + summary: seed.summary, + permalink: `https://api.daily.dev/r/${seed.id}`, + commentsPermalink: `https://app.daily.dev/posts/${seed.id}`, + slug: seed.id, + createdAt: hoursAgo(seed.hours), + image: cover(seed.cover), + readTime: seed.readTime, + numUpvotes: seed.numUpvotes, + numComments: seed.numComments, + numAwards: 0, + source: seed.source, + tags: seed.tags, + type: seed.type ?? PostType.Article, + contentHtml: seed.contentHtml, + domain: seed.domain, + bookmarked: false, + upvoted: false, + commented: false, + read: false, + clickbaitTitleDetected: false, + private: false, + userState: { vote: UserVote.None }, + } as unknown as Post); + +export const mockFeedPosts: Post[] = seeds.map(toMockPost); + +export const mockFeedItems = seeds.map((seed) => ({ + id: seed.id, + post: toMockPost(seed), + score: seed.score, + rationale: seed.rationale, + createdAt: hoursAgo(seed.hours), +})); + +export const mockCollectionPost: Post = { + id: 'mock-collection-1', + title: + 'Zig 0.15 lands the self-hosted backend — what five write-ups agree on', + summary: + 'Merged from five sources covering the same release. All agree LLVM is optional for debug builds and compile times drop sharply; they disagree on whether incremental compilation is usable yet.', + permalink: 'https://api.daily.dev/r/mock-collection-1', + commentsPermalink: 'https://app.daily.dev/posts/mock-collection-1', + slug: 'mock-collection-1', + createdAt: hoursAgo(20), + image: cover(4), + readTime: 6, + numUpvotes: 486, + numComments: 77, + type: PostType.Collection, + tags: ['zig', 'compilers', 'release'], + source: sources.github, + collectionSources: [ + sources.github, + sources.hn, + sources.medium, + sources.infoq, + sources.ph, + ], + numCollectionSources: 5, + bookmarked: false, + upvoted: false, + commented: false, + read: false, + private: false, + userState: { vote: UserVote.None }, +} as unknown as Post; diff --git a/packages/shared/src/graphql/interests.ts b/packages/shared/src/graphql/interests.ts index f2b63110483..6cf9603a108 100644 --- a/packages/shared/src/graphql/interests.ts +++ b/packages/shared/src/graphql/interests.ts @@ -1,5 +1,7 @@ import { gqlClient } from './common'; import type { Post } from './posts'; +import { FEED_POST_FRAGMENT } from './fragments'; +import { USER_POST_FRAGMENT } from './feed'; export enum UserInterestStatus { Active = 'active', @@ -57,10 +59,7 @@ export type InterestFinding = { rationale?: string | null; status: string; createdAt: string; - post?: Pick< - Post, - 'id' | 'title' | 'image' | 'permalink' | 'commentsPermalink' | 'readTime' - > | null; + post?: Post | null; }; const USER_INTEREST_FRAGMENT = ` @@ -100,7 +99,7 @@ export const INTEREST_QUERY = ` `; export const INTEREST_FINDINGS_QUERY = ` - query InterestFindings($id: ID!) { + query InterestFindings($id: ID!, $loggedIn: Boolean! = true) { interestFindings(id: $id) { id postId @@ -109,15 +108,14 @@ export const INTEREST_FINDINGS_QUERY = ` status createdAt post { - id - title - image - permalink - commentsPermalink - readTime + ...FeedPost + contentHtml + ...UserPost @include(if: $loggedIn) } } } + ${FEED_POST_FRAGMENT} + ${USER_POST_FRAGMENT} `; export const CREATE_INTEREST_MUTATION = ` diff --git a/packages/webapp/pages/agent/[id].tsx b/packages/webapp/pages/agent/[id].tsx index 79a94d59b35..8164da823e0 100644 --- a/packages/webapp/pages/agent/[id].tsx +++ b/packages/webapp/pages/agent/[id].tsx @@ -2,107 +2,115 @@ import type { ReactElement } from 'react'; import React, { useEffect, useState } from 'react'; import type { NextSeoProps } from 'next-seo'; import { useRouter } from 'next/router'; -import { format } from 'date-fns'; -import { - Typography, - TypographyType, - TypographyColor, -} from '@dailydotdev/shared/src/components/typography/Typography'; import { Button, ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { TextField } from '@dailydotdev/shared/src/components/fields/TextField'; -import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; -import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; -import { Dropdown } from '@dailydotdev/shared/src/components/fields/Dropdown'; -import Markdown from '@dailydotdev/shared/src/components/Markdown'; -import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; -import { DateFormat } from '@dailydotdev/shared/src/components/utilities/DateFormat'; -import { TimeFormatType } from '@dailydotdev/shared/src/lib/dateFormat'; import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; -import { FlexCol, FlexRow } from '@dailydotdev/shared/src/components/utilities'; +import { FlexCol } from '@dailydotdev/shared/src/components/utilities'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + Tab, + TabContainer, +} from '@dailydotdev/shared/src/components/tabs/TabContainer'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { useQuery } from '@tanstack/react-query'; import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { featureInterestAgent } from '@dailydotdev/shared/src/lib/featureManagement'; -import { - UserInterestCadence, - UserInterestStatus, -} from '@dailydotdev/shared/src/graphql/interests'; import { interestQueryOptions, - interestFindingsQueryOptions, interestPostsQueryOptions, } from '@dailydotdev/shared/src/features/interests/queries'; -import { useSendInterestCommand } from '@dailydotdev/shared/src/features/interests/hooks/useSendInterestCommand'; -import { useUpdateInterest } from '@dailydotdev/shared/src/features/interests/hooks/useUpdateInterest'; import { useDeleteInterest } from '@dailydotdev/shared/src/features/interests/hooks/useDeleteInterest'; +import { useAgentFeed } from '@dailydotdev/shared/src/features/interests/hooks/useAgentFeed'; +import { + AgentProvider, + useAgent, +} from '@dailydotdev/shared/src/features/interests/AgentContext'; +import { AgentHero } from '@dailydotdev/shared/src/features/interests/components/AgentHero'; +import { AgentToolbar } from '@dailydotdev/shared/src/features/interests/components/AgentToolbar'; +import { AgentChatSection } from '@dailydotdev/shared/src/features/interests/components/AgentChatSection'; +import { AgentActivitySection } from '@dailydotdev/shared/src/features/interests/components/AgentActivitySection'; +import { AgentDebugPanel } from '@dailydotdev/shared/src/features/interests/components/AgentDebugPanel'; +import { AgentSettingsModal } from '@dailydotdev/shared/src/features/interests/components/AgentSettingsModal'; +import { + mockAgentPosts, + mockInterest, +} from '@dailydotdev/shared/src/features/interests/mock'; +import { mockConversation } from '@dailydotdev/shared/src/features/interests/chat'; +import type { AgentFeedItem } from '@dailydotdev/shared/src/features/interests/hooks/useAgentFeed'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import ProtectedPage from '../../components/ProtectedPage'; import { getPageSeoTitles } from '../../components/layouts/utils'; -const quickActions = ['Explore more', 'Write me a post']; -const quickActionHints: Record = { - 'Explore more': - 'Runs the agent now to hunt for more matching content. Note: the label is also saved to the refine history.', - 'Write me a post': - 'Runs the agent now and asks it to write a markdown summary post of what it found.', -}; -const sourceLabels: Record<'dailyDev' | 'web' | 'github', string> = { - dailyDev: 'daily.dev', - web: 'Web', - github: 'GitHub', -}; -const outputLabels: Record<'feed' | 'post' | 'notification', string> = { - feed: 'Feed', - post: 'Posts', - notification: 'Notifications', -}; -const cadenceOptions = [ - { value: UserInterestCadence.Hourly, label: 'Every hour' }, - { value: UserInterestCadence.Daily, label: 'Every day' }, - { value: UserInterestCadence.Weekly, label: 'Every week' }, -]; +type AgentTab = 'Chat' | 'Activity' | 'Debug'; -const CardTimestamp = ({ - date, +const AgentPageBody = ({ + items, + postsCount, + onDelete, + isDeleting, }: { - date?: string | null; -}): ReactElement | null => { - if (!date) { - return null; - } + items: AgentFeedItem[]; + postsCount: number; + onDelete: () => void; + isDeleting: boolean; +}): ReactElement => { + const { isSettingsOpen, setSettingsOpen } = useAgent(); + const [tab, setTab] = useState('Chat'); return ( - - - {`· ${format(new Date(date), 'HH:mm')}`} - + <> + + + + controlledActive={tab} + onActiveChange={setTab} + showBorder + > + + + + + + + + + + + + + {isSettingsOpen && ( + setSettingsOpen(false)} + /> + )} + ); }; const Page = (): ReactElement | null => { const router = useRouter(); const id = router.query.id as string; + const forceDemo = router.query.demo === '1'; const { user, isAuthReady } = useAuthContext(); const { value: showAgent } = useConditionalFeature({ feature: featureInterestAgent, shouldEvaluate: isAuthReady, }); - const [feedback, setFeedback] = useState(''); - const [fomo, setFomo] = useState(null); const interestQuery = useQuery(interestQueryOptions(id, user)); - const findingsQuery = useQuery(interestFindingsQueryOptions(id, user)); const postsQuery = useQuery(interestPostsQueryOptions(id, user)); - const { isSending, sendCommand } = useSendInterestCommand(id); - const { isUpdating, updateInterest } = useUpdateInterest(id); + const feed = useAgentFeed({ id, forceDemo }); const { isDeleting, deleteInterest } = useDeleteInterest({ onDeleted: () => router.push(`${webappUrl}agent`), }); @@ -117,323 +125,38 @@ const Page = (): ReactElement | null => { return null; } - const interest = interestQuery.data; - const findings = findingsQuery.data ?? []; - const posts = postsQuery.data ?? []; - const isStopped = interest?.status === UserInterestStatus.Stopped; - - const onSendFeedback = () => { - const trimmed = feedback.trim(); - if (!trimmed || isSending) { - return; - } - sendCommand(trimmed); - setFeedback(''); - }; + const realPosts = postsQuery.data ?? []; + const useMockPosts = forceDemo || (feed.isDemo && !realPosts.length); + const posts = useMockPosts ? mockAgentPosts : realPosts; + const interest = + interestQuery.data ?? (feed.isDemo ? mockInterest : undefined); return ( - - - - - - - - - - -
- )} - - {interest && ( - - - Settings - - - - {`FOMO vs quality: ${(fomo ?? interest.fomoThreshold).toFixed( - 2, - )} (higher = only the best)`} - - setFomo(value)} - onValueCommit={([value]) => - updateInterest({ fomoThreshold: value }) - } - /> - - - - Cadence - - value === interest.cadence, - ), - )} - options={cadenceOptions.map(({ label }) => label)} - disabled={isUpdating || isStopped} - onChange={(_, index) => { - const cadence = cadenceOptions[index]?.value; - if (cadence && cadence !== interest.cadence) { - updateInterest({ cadence }); - } - }} - /> - - - - Sources - - {(['dailyDev', 'web', 'github'] as const).map((key) => ( - - updateInterest({ - sources: { [key]: !interest.sources?.[key] }, - }) - } - > - {sourceLabels[key]} - - ))} - - - - Outputs - - {(['feed', 'post', 'notification'] as const).map((key) => ( - - updateInterest({ - outputModes: { [key]: !interest.outputModes?.[key] }, - }) - } - > - {outputLabels[key]} - - ))} - - - )} - - - - Command the agent - - - {quickActions.map((action) => ( - - - - ))} - -
- + + + - -
-
- - - - Posts - - {!postsQuery.isPending && !posts.length && ( - - No generated posts yet. - - )} - {posts.map((post) => ( - - - {post.title} - - - {post.contentHtml && } - - ))} - - - - - - - Feed - - - Sorted by relevance score - - - - - - - {findingsQuery.isPending && ( - Loading… - )} - {!findingsQuery.isPending && !findings.length && ( - - Nothing in your feed yet. The agent may still be hunting — try - Refresh in a moment. - - )} - {findings.map((finding) => { - const href = - finding.post?.commentsPermalink ?? finding.post?.permalink; - const title = finding.post?.title ?? finding.postId; - const body = ( - - - {title} - - - {`Score ${finding.score.toFixed(2)}`} - {finding.rationale ? ` · ${finding.rationale}` : ''} - - - - ); - - return href ? ( - - - {body} - - - ) : ( -
- {body} -
- ); - })} -
- + + + deleteInterest(id)} + isDeleting={isDeleting} + /> + ); };