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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions packages/shared/src/features/interests/AgentContext.tsx
Original file line number Diff line number Diff line change
@@ -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<AgentContextValue>({} 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<AgentActivityItem[]>([]);
const [messages, setMessages] = useState<AgentMessage[]>(initialMessages);
const [isSettingsOpen, setSettingsOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();

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<AgentContextValue>(
() => ({
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 (
<AgentContext.Provider value={value}>{children}</AgentContext.Provider>
);
};
109 changes: 109 additions & 0 deletions packages/shared/src/features/interests/chat.ts
Original file line number Diff line number Diff line change
@@ -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: `<p>Spawned. I'll hunt daily and only ping you when something clears your bar.</p><p>First pass over daily.dev turned up <strong>one</strong> thing I'd stop and read today:</p>`,
},
{ type: 'posts', posts: [mockFeedPosts[0]] },
{
type: 'text',
html: `<p>Eight more cleared the bar but are lower confidence.</p>`,
},
{ 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: `<p>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.</p><p>Re-scored what I already had — tap any of these to see why I kept it:</p>`,
},
{
type: 'picks',
posts: [mockFeedPosts[4], mockFeedPosts[7], mockFeedPosts[1]],
},
],
},
{
id: 'msg-5',
role: 'agent',
at: minutesAgo(42),
isScheduled: true,
blocks: [
{
type: 'text',
html: `<p>Daily run — scanned <strong>128</strong> posts, kept 6.</p><p>Five sources covered the same release, so I merged them into one card instead of sending you five:</p>`,
},
{ type: 'collection', post: mockCollectionPost },
{
type: 'text',
html: `<p>Here's the write-up you asked me to keep doing:</p>
<h3>Zig this week</h3>
<p><strong>The one thing to read:</strong> 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.</p>
<p><strong>Worth cloning:</strong> 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.</p>
<p><strong>What I skipped:</strong> four "Zig vs Rust" posts re-running the same microbenchmark, and two release announcements with no notes attached.</p>`,
},
{
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: `<p>Applied <strong>“${command}”</strong> and re-ran the hunt. It's saved as standing guidance, so every future run and live match uses it too.</p><p>Closest matches under the new weighting:</p>`,
},
{ 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 },
];
Original file line number Diff line number Diff line change
@@ -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<AgentActivityKind, ReactElement> = {
run: <RefreshIcon size={IconSize.XSmall} />,
command: <AiIcon size={IconSize.XSmall} />,
finding: <MagicIcon size={IconSize.XSmall} />,
post: <FeatherIcon size={IconSize.XSmall} />,
notification: <BellIcon size={IconSize.XSmall} />,
};

const ActivityRow = ({ item }: { item: AgentActivityItem }): ReactElement => (
<FlexRow className="items-start gap-3">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-8 bg-surface-float text-text-tertiary">
{kindIcon[item.kind]}
</span>
<FlexCol className="gap-0.5">
<Typography type={TypographyType.Callout}>{item.text}</Typography>
<Typography
type={TypographyType.Caption1}
color={TypographyColor.Tertiary}
>
<DateFormat date={item.at} type={TimeFormatType.Post} />
</Typography>
</FlexCol>
</FlexRow>
);

export const AgentActivitySection = (): ReactElement => {
const { activity } = useAgent();
const items = [...activity, ...mockActivity];

return (
<FlexCol className="gap-4 py-2">
{items.map((item) => (
<ActivityRow key={item.id} item={item} />
))}
</FlexCol>
);
};
Loading
Loading